Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 701f78ea2e Release 0.7.1: fix the macOS build
eframe 0.36's App::on_exit takes the glow context as a second argument.
The macOS-only menu-bar Quit path still called on_exit(self), so the
0.7.0 macOS release job failed with E0061. Pass None; our on_exit ignores
it. macOS isn't built in CI, so only the release build caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:13:08 -04:00
Brandon ThomasandClaude Opus 5.5 25ef65c81e Release prep: 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:33 -04:00
BFlat 76b5377868 Merge pull request #998 from storytold/claude/integrate-batch-31
Integration batch 31: translations, raw header previews, XMP import warnings, Apple Photos, HDR histogram, JPEG XL, Edit In for many photos
2026-10-11 22:02:29 +07:00
Claude 5343fd10ff LR-EXP-TYPE: the Export dialog is 615 pt wide with JPEG XL in the format row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 14:49:19 +00:00
Claude 9e41805a12 M-edit-in: the many-photos test uses distinct files and edits before the second copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 14:31:29 +00:00
Claude 3b306259e5 M-edit-in: the copies toast is a format string (move it to the -formats catalogs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 14:05:56 +00:00
Claude 76a2899383 Merge remote-tracking branch 'origin/main' into claude/integrate-batch-31 2026-10-11 14:04:21 +00:00
BFlat 995d5ea49b Merge pull request #981: integration batches 27–30 (80 reviewed contributor PRs)
Integration batches 27–30: 80 reviewed contributor PRs
2026-10-11 21:00:14 +07:00
Claude ea51574172 Merge batch 30 fixes 2026-10-11 13:37:49 +00:00
Claude f01d3304e2 Batch 30: parity summary refreshed (cargo xtask parity --write)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 13:37:12 +00:00
Claude 45b174a781 Batch 30: one tracker row each for LR-PREF-DISPLAY / -PERFORMANCE / -PEOPLE (a rename-crossing merge doubled them)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 13:34:22 +00:00
Claude 4ebb934c56 Merge PR #677
On merge: the HDR histogram is applied in develop_reporting (#712 split develop), is skipped for
draft renders (a second render per drag tick), and HdrBins::bin clamps a hand-built split so it
can't underflow (test hdr_bins_with_any_split_stay_in_range).
2026-10-11 13:09:34 +00:00
Claude 9cf47c86fe Merge batch 30 fixes 2026-10-11 13:08:50 +00:00
Claude 7fe8f8dbd1 LR-BEHAV-UNDO: each rename-provenance fixture gets its own folder (#874's tests shared one and failed when run in parallel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 13:08:37 +00:00
Claude 4ff1d9761d Batch 30: a selected slider keeps only its arrow keys, so shortcuts work after a slider click (#884); tests follow the merged dialog and panel state
#884 focuses a develop slider on click so the wheel and arrows adjust it, but a focused widget
owned the whole keyboard, so after touching a slider no shortcut fired (O for the mask overlay,
caught by mask_adjustments_hide_overlay_without_disabling_the_mask). Now a selected slider owns
the arrow keys only. The wheel test opens the Edit panel explicitly (the demo may start on it, and
panel.edit then toggles it shut), and the Export dialog snapshot is 546 px wide since #833's
nine watermark positions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:59:15 +00:00
Claude 9978dc739d Merge PR #643
# Conflicts:
#	crates/engine/src/export.rs
#	crates/engine/src/tests_export.rs
#	crates/mcp/src/headless.rs
#	crates/mcp/src/tools.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-11 12:43:20 +00:00
Claude 0d5f8b1ea4 Merge PR #879
# Conflicts:
#	docs/whats-new.md
2026-10-11 12:42:19 +00:00
Claude 705c59cb42 Docs: PSD export is no longer an open file-write gap (#953); the CLI help lists .psd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:42:10 +00:00
Claude 6bc25940b8 Merge PR #780 (via claude/integrate-780) 2026-10-11 12:41:50 +00:00
Claude bd3b058a8f M-export: bump RENDER_CACHE_VERSION for the XYB JPEG XL decode fix
PR #780 changes how lossy (XYB) wide-gamut JPEG XL files decode (crates/codecs/src/jxl.rs), so cached renders of those photos are stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:41:12 +00:00
Claude f4a205b4ff M-export: pin jixel to =0.3.4
Young crate with a fast release cadence; take updates deliberately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:41:00 +00:00
Claude 255b23816d LR-EXP-PSD: Edit In makes a copy of every selected photo and opens them all (#920)
photo.editExternal takes the selection (or ids, at most 50): each photo gets its own -Edit copy
stacked on its original, the copies are selected and returned in copies (path / id / original
still name the first). Photo ▸ Edit In opens each copy in the editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:40:58 +00:00
Claude 4a0d04b994 Merge PR #780
JPEG XL export via the pure-Rust jixel crate (robmdunn; fixes #247).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:40:55 +00:00
Claude 4ec3494733 Merge PR #434
On merge: Lightroom's default profiles (Adobe Standard, Adobe Color), named by nearly every sidecar,
give no camera-profile warning; other profiles still do (test default_lightroom_profiles_are_not_reported).
The import help string keeps main's onDeleted wording and gains warnings.
2026-10-11 12:38:16 +00:00
Claude e4a248c91f Merge PR #755
On merge: the quick preview path orients like the whole-file path (preview_orientation with the
raw's header prefix, so a portrait raw whose embedded JPEG carries no orientation is not sideways),
and its doc links point at preview_window.
2026-10-11 12:37:14 +00:00
Claude c54328b2d3 Merge PR #725
On merge: translations overlaid on main's zh-hans.json, so the keys main added since (docking panels and
others) stay.
2026-10-11 12:36:44 +00:00
Claude eae1eb5ba2 Merge PR #822
On merge: translations overlaid on main's ru.json, so the keys main added since (docking panels and
others) stay.
2026-10-11 12:36:44 +00:00
Claude 870eaa74e6 KEY-SLIDER-WHEEL: the wheel-nudge test reads exposure as a number (follow-up to #884)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:34:32 +00:00
Claude fce79feadb LR-IMP-FORMATS: the R5 Mark II QP-map test is a test again (its #[test] was lost merging #915 next to #829)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:33:43 +00:00
Claude 44f86dc855 LRC-LIB-FOLDERS: drop an unused import (#947 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:33:07 +00:00
Claude 47b5e0ae1b Batch 30: cargo fmt over the merged PRs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:32:03 +00:00
Claude 8df1ad0392 Merge main (batch 26 landed) 2026-10-11 12:31:18 +00:00
BFlat 0dd6593d9d Merge pull request #975: integration batch 26 (31 reviewed contributor PRs)
Integration batch 26: 31 reviewed contributor PRs
2026-10-11 19:30:57 +07:00
Claude 37b26c56a3 Merge batch 29b (#946, #947, #953, #950)
# Conflicts:
#	Cargo.toml
#	crates/engine/src/rename.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/icons.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/menubar.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/left.rs
#	docs/whats-new.md
2026-10-11 12:08:44 +00:00
Claude d761e1b231 LR-PREF-INTERFACE: sort the test module list after merging #950 (rustfmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:07:17 +00:00
Claude 155ad70c6e LR-PREF-INTERFACE: French and Ukrainian appearance strings (modes, themes)
PR #950 gave fr and uk only the three favourites strings; add the thirteen
Appearance Mode / theme strings the other locales got, translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:06:54 +00:00
Claude 10ebeccdc8 Merge PR #950
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/src/state.rs
#	docs/parity-checklist.md
2026-10-11 12:06:31 +00:00
Claude 2e5a9f92ca LR-EXP-PSD: settings path fields use TextField; Windows default editor via Explorer; PackBits encoder without indexing
- Settings ▸ General ▸ External Editing: the PhotoCraft location and Other editor
  fields move from egui::TextEdit to TextField (edit menu, Esc restores).
- Edit In with the system default on Windows starts explorer.exe with the file
  as one argument instead of cmd /C start, which read & ^ % | in file names as
  shell syntax.
- psd::packbits walks the row with first()/get()/split_at_checked instead of
  direct indexing and slicing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:05:13 +00:00
Claude acdfa08f00 Merge branch 'claude/fix-922-denoise-toggle'
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 12:04:31 +00:00
Claude b89fb8bbab Merge branch 'claude/fix-942-cube-import-messages'
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 12:04:31 +00:00
Claude e6af53d46c LR-EDIT-DETAIL-DENOISE: a remote denoise tile with values that aren't finite fails, and runs on this computer instead (follow-up to #813)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:04:23 +00:00
Claude d1e20f0292 LR-BEHAV-LOCALIZE: the Latin-only test face has a neutral name (follow-up to #750)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:04:23 +00:00
Claude b04c2c831c LR-CLOUD-SMARTPREVIEW: the shard folder name is taken with get(), not a slice (follow-up to #834)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:04:23 +00:00
Claude e1016acc0f Merge PR #953
# Conflicts:
#	crates/codecs/src/lib.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-11 12:03:58 +00:00
Claude 8c4898ec04 Merge PR #645
# Conflicts:
#	crates/engine/src/media.rs
#	crates/pipeline/src/finish.rs
#	crates/pipeline/src/local.rs
2026-10-11 12:03:53 +00:00
Claude 59011d114f Merge PR #617
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 12:03:31 +00:00
Claude f7ffa1433c Issue #947: compare a cross-volume move's copy in 1 MiB chunks; Move to Folder… uses the shared folder picker
move_across_volumes read both whole files into memory to compare them; it now
compares lengths first, then streams 1 MiB chunks. The grid's Move to Folder…
asks through pick::ask (background dialog where the host has one) like the
other folder pickers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:03:29 +00:00
Claude 33686e6951 Merge PR #734
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/media.rs
#	crates/raw/tests/corpus.rs
#	docs/parity-checklist.md
2026-10-11 12:03:15 +00:00
Claude 45a6a42ebf Merge PR #947
# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
2026-10-11 12:02:51 +00:00
Claude a0e422ce4a Merge PR #752
# Conflicts:
#	crates/engine/src/cmd/develop.rs
#	crates/engine/src/files.rs
#	docs/mcp.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-11 12:02:46 +00:00
Claude 56a62f6d8b LRC-LIB-CATALOG-SWITCH: a failed library switch keeps the current library open; library names valid on Windows
Check the target (Lightroom catalog, file, unreadable folder, locked by another
program) with Session::check_library before closing the open library, and if
opening still fails, reopen the previous one (both errors reported if that fails
too). New Library names reject Windows-reserved characters and trailing dots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:02:43 +00:00
Claude 6b22c5ca25 Merge PR #789
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/panels/left.rs
#	docs/parity-checklist.md
2026-10-11 12:02:10 +00:00
Claude c53b61732d Merge PR #738
On merge: the XWayland default goes in front of the catch_unwind-wrapped launch (#951), and the
README's Linux section names the LIGHTCRAFT_WAYLAND=1 opt-out.
2026-10-11 12:01:41 +00:00
Claude 76fc27a7bc Merge PR #791
# Conflicts:
#	Cargo.lock
#	apps/lightcraft/src/main.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-11 12:01:19 +00:00
Claude c9d131bfbc Merge PR #946
# Conflicts:
#	crates/engine/src/library.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/settings.rs
#	crates/ui-egui/src/state.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-11 12:01:18 +00:00
Claude 8339028a80 Merge PR #794
# Conflicts:
#	crates/engine/src/media.rs
#	crates/pipeline/src/optics.rs
#	docs/parity-checklist.md
2026-10-11 12:00:57 +00:00
Claude 6308ca1088 Merge PR #547
# Conflicts:
#	crates/ui-egui/src/panels/detail.rs
2026-10-11 12:00:46 +00:00
Claude 11f41cbe97 Merge PR #813 2026-10-11 12:00:21 +00:00
Claude 7ff683f037 Merge PR #750 2026-10-11 12:00:20 +00:00
Claude 477c078b35 Merge PR #834 2026-10-11 12:00:20 +00:00
Claude 5ba413baf2 Merge PR #625
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity-checklist.md
2026-10-11 12:00:20 +00:00
Claude 421000916e Merge PR #667
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 12:00:19 +00:00
Claude 860b0d69c6 Merge PR #749 2026-10-11 12:00:19 +00:00
Claude 111bb42aa3 Merge PR #825
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 12:00:17 +00:00
Claude 8380415222 Merge PR #874
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 12:00:17 +00:00
Claude d7f868fc6d LR-EDIT-DETAIL-DENOISE: dismissing the model setup turns AI Denoise back off (#922)
Turning on AI Denoise without a model opens Settings > AI Denoise and
keeps a pending request, so the switch showed checked and Settings
offered "Cancel pending action". Closing the setup with no model
download or installation running now drops that request: the switch
goes back off and nothing claims to be pending. A download started
before closing still turns AI Denoise on when it finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:00:15 +00:00
Claude da008ce53c LR-IMP-PROFILES: .cube-only imports report their profiles and failures (#942)
File > Import Profiles & Presets... now builds one toast for both halves
(a second toast replaced the first, so a .cube-only import said "No new
presets"). The preset half is skipped when no preset files were picked,
and profile.import's failures are shown with their reason. profile.import
also reports .cube files and zips it couldn't read instead of dropping
them silently. The command's result carries the profile half under
"profiles".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:00:08 +00:00
Claude 5df7f733d3 Batch 29: tracker rows for content-aware Fill (#926), printer ICC soft proofing (#928) and the lensfun-format lens database (#930)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:58:25 +00:00
Claude 7ecb0b0191 Merge PR #915
# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/vendor/crx.rs
#	crates/ui-egui/locales/de.json
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-11 11:58:15 +00:00
Claude 17dfa9709e Merge PR #926
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	xtask/src/layers.rs
2026-10-11 11:57:45 +00:00
Claude 3b0131aaea Merge batch 28 fix 2026-10-11 11:57:35 +00:00
Claude 2efa01767f Batch 28: resolve the leftover #685 test conflict in tests_panels.rs (both album tests kept)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:57:29 +00:00
Claude 05939cbc54 Merge PR #928
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
2026-10-11 11:53:13 +00:00
Claude b8d39063db Merge PR #930 2026-10-11 11:53:12 +00:00
Claude 5861591307 Merge PR #948
On merge: metadata.downloadPlaceNames accepts plain http only for the loopback address itself
(http://127.0.0.1.example.com/ and http://127.0.0.1@host/ are refused; test
download_base_is_https_or_loopback_only).
2026-10-11 11:52:57 +00:00
Claude 6c18c0a7c6 LR-PREF-PERFORMANCE: preview builds render the larger of view and thumbnail first (a view under 512 px no longer feeds an upscaled thumbnail, follow-up to #927)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:52:24 +00:00
Claude 95007492cc Merge PR #927 2026-10-11 11:52:07 +00:00
Claude 5ce115a505 Merge PR #959 2026-10-11 11:51:56 +00:00
Claude 62ece65ac9 Merge PR #664
# Conflicts:
#	crates/engine/src/tests_export.rs
#	crates/mcp/src/headless.rs
#	crates/mcp/src/tools.rs
2026-10-11 11:50:59 +00:00
Claude 44acde3a41 Merge PR #666
# Conflicts:
#	apps/lightcraft/src/main.rs
#	crates/engine/src/config.rs
2026-10-11 11:50:35 +00:00
Claude c04eec3f9d Merge PR #721
# Conflicts:
#	apps/lightcraft/src/main.rs
2026-10-11 11:50:22 +00:00
Claude b6ad706aa6 Merge PR #712
# Conflicts:
#	crates/engine/src/export.rs
2026-10-11 11:50:06 +00:00
Claude 31734dea86 Merge PR #689
# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/vendor/arw.rs
2026-10-11 11:50:05 +00:00
Claude 76cf8ca4c9 Merge PR #694
# Conflicts:
#	crates/gpu/src/backend.rs
#	docs/parity-checklist.md
2026-10-11 11:49:53 +00:00
Claude 4e6cd5d7b9 Merge PR #704
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 11:49:37 +00:00
Claude 9dc0d0b62b Merge PR #685
# Conflicts:
#	crates/ui-egui/src/panels/grid.rs
#	crates/ui-egui/src/tests_panels.rs
#	docs/parity-checklist.md
2026-10-11 11:49:25 +00:00
Claude efd29ef21a Merge branch 'claude/integrate-batch-27' into claude/integrate-batch-28 2026-10-11 11:48:59 +00:00
Claude 72d77d7b41 Batch 27: close two tests in tests_masking.rs (the merges of #884 and #766 dropped their braces)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:48:59 +00:00
Claude 2fedd92077 Batch 28: close the faces-toggle test (the merge of #690 dropped its brace)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:48:32 +00:00
Claude 40492d238e Merge PR #690
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/tests_panels.rs
#	docs/parity-checklist.md
2026-10-11 11:48:06 +00:00
Claude 8d319563ca Merge PR #710
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-11 11:48:05 +00:00
Claude edb95894b4 Merge PR #695
# Conflicts:
#	crates/engine/src/media.rs
2026-10-11 11:47:51 +00:00
Claude 16bbaff14c Merge PR #700
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity-checklist.md
2026-10-11 11:47:50 +00:00
Claude db8b82c6d7 Merge PR #703 2026-10-11 11:47:28 +00:00
Claude 7c4fd3abaa Merge PR #686 2026-10-11 11:47:28 +00:00
Claude 7afbc3ecb3 Merge PR #687 2026-10-11 11:47:28 +00:00
Claude 0f9415b320 Merge PR #688 2026-10-11 11:47:28 +00:00
Claude 27d2bfda86 Merge PR #663
# Conflicts:
#	docs/gaps.md
2026-10-11 11:47:28 +00:00
Claude d098ffac42 Merge PR #665 2026-10-11 11:47:27 +00:00
Claude ae2d936c4d Merge PR #679 2026-10-11 11:47:27 +00:00
Claude 96988afe6d Merge PR #696 2026-10-11 11:47:26 +00:00
Claude 1f3bfbdb25 Merge PR #698 2026-10-11 11:47:26 +00:00
Claude 9847383bf2 Merge PR #711
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 11:47:26 +00:00
Claude ab86ed7c84 Merge PR #716
# Conflicts:
#	docs/gaps.md
2026-10-11 11:47:26 +00:00
Claude b46adf0640 Merge PR #720
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 11:47:25 +00:00
Claude c97b2790d3 Merge PR #843
# Conflicts:
#	crates/engine/src/export.rs
#	docs/parity-checklist.md
2026-10-11 11:46:15 +00:00
Claude 82f74e31fd Merge PR #727
# Conflicts:
#	crates/raw/src/vendor/nef.rs
#	docs/parity-checklist.md
2026-10-11 11:46:01 +00:00
Claude 1d11e5bd89 Merge PR #739
# Conflicts:
#	crates/ui-egui/src/panels/detail.rs
#	docs/parity-checklist.md
2026-10-11 11:45:30 +00:00
Claude 06ef27bb37 Merge PR #751
# Conflicts:
#	docs/parity-checklist.md
2026-10-11 11:44:45 +00:00
Claude f7730b5877 View: end_frame keeps its doc comment (#945 put lights_out under it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:44:33 +00:00
Claude 34b55101fe Merge PR #786
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panels/dialogs.rs
2026-10-11 11:44:25 +00:00
Claude 3ac2b21d9e Merge PR #900
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-11 11:44:12 +00:00
Claude 5ced33fd9a Merge PR #951
# Conflicts:
#	apps/lightcraft/src/main.rs
2026-10-11 11:44:01 +00:00
Claude 0d022b7506 Merge PR #938
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/media.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-11 11:43:40 +00:00
Claude 38f16ff1b4 Merge PR #945 2026-10-11 11:43:08 +00:00
Claude 33a29dcdc3 Merge PR #907 2026-10-11 11:43:08 +00:00
Claude 36cd72bb33 Merge PR #925 2026-10-11 11:43:08 +00:00
Claude 605641ab04 Merge PR #952 2026-10-11 11:43:07 +00:00
Claude 9efc0950cf Merge PR #887 2026-10-11 11:43:07 +00:00
Claude fbc7ba042b Merge PR #890 2026-10-11 11:43:07 +00:00
Claude 4e473b2b37 Merge PR #923 2026-10-11 11:43:07 +00:00
Claude 63059a4303 Merge PR #961 2026-10-11 11:43:07 +00:00
Claude 0ea19a8a62 Merge batch 26 fmt 2026-10-11 11:43:06 +00:00
Claude 47fe7f70be Batch 26: cargo fmt over the merged PRs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:42:57 +00:00
Claude cb5cd22bd3 Merge PR #723
# Conflicts:
#	docs/whats-new.md
2026-10-11 11:42:45 +00:00
Claude cba621e55c Merge PR #854
# Conflicts:
#	crates/engine/src/media.rs
2026-10-11 11:42:42 +00:00
Claude 4f9b77cad9 Merge PR #766
# Conflicts:
#	crates/pipeline/src/masks.rs
#	crates/ui-egui/src/tests_masking.rs
#	docs/parity-checklist.md
2026-10-11 11:42:20 +00:00
Claude 814e35b103 Merge PR #884
# Conflicts:
#	crates/ui-egui/src/tests_masking.rs
2026-10-11 11:41:37 +00:00
Claude 8df7b008c0 Merge PR #847 2026-10-11 11:41:21 +00:00
Claude 0449a3fc51 Merge PR #845 2026-10-11 11:41:21 +00:00
Claude 1ac1bc009b Merge PR #833 2026-10-11 11:41:21 +00:00
Claude ee2cd6f4bb Merge PR #726 2026-10-11 11:41:20 +00:00
Claude 17e1a34d14 Merge PR #728 2026-10-11 11:41:20 +00:00
Claude 4b93c90024 Merge PR #729 2026-10-11 11:41:20 +00:00
Claude e2a38c26e0 Masking: keep mask_overlay's doc comment on mask_overlay (#957 put the eyedropper between them)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:40:59 +00:00
squareSphere29 761a91c233 fix: EXIF values under histogram run together (#913)
- Filter empty EXIF values and join with ' · ' separator (matches grid.rs pattern)
- Single centered text element — no overlap possible
- Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing

(cherry picked from commit 44a29d7fc7)
2026-10-11 11:40:54 +00:00
squareSphere29 a33dc70a3c fix: EXIF values under histogram run together (#913)
- Filter empty EXIF values instead of rendering empty strings at fixed positions
- Distribute remaining items evenly across available width using (i + 0.5) / count
- Apply left/center/right alignment dynamically based on item position
- Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing

(cherry picked from commit 28ab23e633)
2026-10-11 11:40:54 +00:00
Claude bb4cdeed2a Merge PR #957 2026-10-11 11:40:42 +00:00
Claude 805a15ae9b Merge PR #943 2026-10-11 11:40:42 +00:00
Claude 69b90ee7c4 LR-META-EXIF-FULL: tags.rs back to LF line endings (#831 mixed in CRLF)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:40:41 +00:00
Claude 170f3956a1 Merge PR #831 2026-10-11 11:40:41 +00:00
ClaudeandVikram Bhaskaran 2bc33268d2 Issue #793: the last two mentions of target/web point at target/web-site (from #888)
Co-Authored-By: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:40:34 +00:00
Claude 0e4b52e29b Merge PR #746
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-11 11:40:26 +00:00
Claude dba63bc9d5 Merge PR #747
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-11 11:40:17 +00:00
Claude 69f117632b Merge PR #754
# Conflicts:
#	crates/engine/src/media.rs
2026-10-11 11:39:46 +00:00
Claude 5350a8ed70 Merge PR #773
# Conflicts:
#	crates/ui-egui/src/panels/detail.rs
2026-10-11 11:39:40 +00:00
Claude aa48f6f6e7 Merge PR #935 2026-10-11 11:39:28 +00:00
Claude f9f3fbe4db Merge PR #774 2026-10-11 11:39:28 +00:00
Claude f4d8a428ac Merge PR #848 2026-10-11 11:39:28 +00:00
Claude f9919acc7f Merge PR #785 2026-10-11 11:39:28 +00:00
Claude f8c01f70fb Merge PR #748 2026-10-11 11:39:27 +00:00
Claude 8f42f4a96d Merge PR #753 2026-10-11 11:39:27 +00:00
Claude d5d9daa555 Merge PR #759 2026-10-11 11:39:27 +00:00
Claude bda8569f67 Merge PR #760 2026-10-11 11:39:27 +00:00
Claude 0a46f615dc Merge PR #767 2026-10-11 11:39:26 +00:00
Claude b23b20638e Merge PR #772 2026-10-11 11:39:26 +00:00
Claude 9ecd3309b2 Merge PR #779 2026-10-11 11:39:26 +00:00
Claude 5abc39ae7d Merge PR #799 2026-10-11 11:39:26 +00:00
Claude 11dd3bd6b3 Merge PR #803 2026-10-11 11:39:26 +00:00
Claude 68f75cc07a Merge PR #809 2026-10-11 11:39:26 +00:00
Claude 72b02c65b7 Merge PR #817 2026-10-11 11:39:25 +00:00
Claude 5bbce8b9fd Merge PR #855 2026-10-11 11:39:25 +00:00
Claude f30ad32d93 Merge PR #858 2026-10-11 11:39:25 +00:00
Claude 09461d04c7 Merge PR #873 2026-10-11 11:39:25 +00:00
Claude f98749343f Merge PR #877 2026-10-11 11:39:25 +00:00
Claude 95662cfff5 Merge PR #883 2026-10-11 11:39:25 +00:00
Claude 14679de823 Merge PR #885 2026-10-11 11:39:25 +00:00
Claude b038c018c2 Merge PR #829 2026-10-11 11:39:24 +00:00
Claude 72a7cd80b8 Merge PR #792 2026-10-11 11:39:24 +00:00
xristosmoug 0b960d39e3 fix(macos): verify strict signature in release DMG (#863) 2026-10-11 13:26:48 +02:00
ingolfteetzandClaude Opus 5.5 bbd973f321 LRC-LIB-CATALOG-IMPORT: describe raws from their headers without reading pixel data
`lightcraft_raw::sparse::header_plan` says which byte ranges of a raw file the header
probe (`probe_info`, `lightcraft_meta::extract`) reads, given a buffer of the file's length
in which only some ranges are loaded: everything but the bulk sample data (large TIFF
strips/tiles minus the first chunk's coding headers, a CR3's image tracks minus the raw
track's CRX headers and QP maps, a RAF's CFA strip, an RW2's raw data). `preview_plan`
also reads what `embedded_preview` needs, for raws shown from their preview. NEF, ORF and
PEF (their header probe looks at samples) and anything else are read whole.

The corpus test checks, for every raw.pixls.us sample the plan describes, that probing the
partly loaded buffer gives exactly what the whole file gives, with the skipped bytes zero and
again 0xFF: CR3 2.7 %, DNG 2.4 %, CR2 3 %, RAF 6 %, RW2 7 %, ARW 8 % of their bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:25:57 +02:00
Andrew FrahnandClaude Opus 5.5 a7382aacfb Masking: Color Range "Pick" samples the photo
With the Masking panel open the loupe routes clicks to mask_overlay, so the
colorRange eyedropper armed by the Pick button (handled only in
general_interaction) never ran and no sample was taken. mask_overlay now
handles the eyedropper (crosshair cursor, click samples, shift-click adds)
through a shared color_range_pick helper.

Fixes #954

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 22:20:03 +11:00
Lucas LepskiandGPT-6.1 Sol 353c1cf566 UI-PILL: retain padding fix with upstream toolbar layout
Preserve upstream compact-toolbar layout and keyboard regressions alongside the rating and flag padding regression.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
2026-10-11 13:07:05 +02:00
sanyamsingh001andCodex 5a60d94f2f KEY-SLIDER-TYPE: fix immediate numeric entry and Reset precedence
Honor Enter/Escape on the completed opening release, release focus for a
cancelled opening click, and keep a numeric Escape batch out of app shortcuts.
Defer the Edit panel's clicked Reset until after its controls so the normal
click-away commit precedes the explicit reset. Retain the original Numeric
regressions and the single active-draft Reset regression.

Co-authored-by: Codex <codex@openai.com>
2026-10-11 10:55:33 +00:00
Andrew FrahnandClaude Opus 5.5 88e4188e78 Fix #812: the window's renderer can't kill the start-up (DX12 shader errors)
On some Windows machines FXC fails to compile egui's own shaders on DX12
("The parameter is incorrect. (0x80070057)" in egui_pipeline) and wgpu's
default error handler panicked. Each renderer (DX12, Vulkan, GL, glow) is
now probed with error scopes and a logging error handler before the window
opens; the one that works is remembered; a start that dies leaves a marker
and relaunches with the next renderer. LIGHTCRAFT_GPU_BACKEND=glow added.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 21:45:02 +11:00
Luniiya 43662e6959 UI: add favorite themes and appearance shortcuts
Port of storytold/photocraft#2445. Each theme in Settings > Interface gets a
star; starred themes are saved with the settings and listed in a right-click
menu on the title bar's appearance button, next to Appearance Settings, so
switching between favorites takes one click. The theme cards now list one
theme per row to make room for the stars, so the settings dialog test
scrolls further to reach the controls below them.
2026-10-11 12:24:01 +02:00
Andrew FrahnandClaude Opus 5.5 23e046daba Reverse geocoding: surface download failures, cache the gazetteer, e2e download test
- A failed, cancelled or finished place-names download is reported once
  as a toast (failures as an error toast that says what to do), and
  metadata.placeNamesStatus reports downloading / lastDownload / lastError.
- The parsed GeoNames data is cached in the session and re-read only when
  the cities file's size or modification time changes.
- End-to-end download test against a fake HTTP server on 127.0.0.1:
  a synthetic zip, a garbage zip, a truncated body and 404s.
- Info panel: Fill Location gets its own row (it was clipped beside
  Show on Map in a narrow panel).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 21:09:53 +11:00
Andrew FrahnandClaude Opus 5.5 d94d6856e9 Library switching: name prompt, library name under My Photos, docs (#902)
- New Library… from the menu asks for a name (default prefilled), then the location.
- The open library's name shows under My Photos in the left panel (hover: its path), so it
  is visible on macOS too, where the window title isn't.
- Switching also remembers the library being left (e.g. the one opened at launch).
- whats-new entry and parity row LRC-LIB-CATALOG-SWITCH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 21:08:24 +11:00
Andrew FrahnandClaude Opus 5.5 b28b05c475 Reverse geocoding: fill City / State / Country from GPS (offline GeoNames)
New crate lightcraft-geocode (L1): parses the GeoNames cities dump and finds the
nearest place within 50 km through a 1-degree cell index; NaN / out-of-range
positions and malformed lines find nothing. Engine commands
metadata.reverseGeocode (Photo menu, Info panel "Fill Location"; fills only
empty fields unless overwrite, one undo step, activity row for batches),
metadata.downloadPlaceNames (background download of the CC-BY 4.0 GeoNames
files into the settings folder, cancellable activity row) and
metadata.placeNamesStatus. No data is bundled; NOTICE records the licence.

Fixes #582.

Sources: GeoNames dump readme (prose); haversine (textbook); PKWARE APPNOTE
(ZIP, prose); cell index own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:49:36 +11:00
Andrew FrahnandClaude Opus 5.5 8924ea9afd Library: create folders on disk and move photos between folders
folder.create {parent, name} makes a folder on disk (one undo step; undo
removes it again while it is empty). photo.moveToFolder {folder, ids?}
moves the photos' files with their XMP sidecars into another folder and
relinks them as one undo step (undo moves them back); virtual copies
follow their master. It never overwrites: a name already taken in the
destination refuses the whole move with an actionable error. Missing or
offline originals and Local photos are refused. Across volumes a file is
copied, compared byte for byte and only then removed. The move holds a
row in the activity stack.

UI: New Folder on Disk… and Move Selected Photos Here on a Folders row,
Move to Folder… in the photo context menu; strings in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:49:19 +11:00
Andrew FrahnandClaude Opus 5.5 75cf78175a Library switching: New Library…, Open Recent Library, library name in the title (#902)
- app.newLibrary {path?, name?}: create a library (in a chosen location, or a given folder)
  and switch to it; refuses an existing library, a non-empty folder, a file or a bad name.
- File ▸ Open Recent Library: the last 8 libraries (app settings), missing ones greyed out;
  opening a gone one is an error that forgets it instead of creating an empty library.
  app.recentLibraries / app.clearRecentLibraries for CLI and MCP.
- The window title names the open library; Settings ▸ General gains New Library….
- A library that can't be opened leaves the current one open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:48:36 +11:00
Andrew FrahnandClaude Opus 5.5 457c899160 Library: a library's own file names its folder; any other file is a clear error
`lightcraft-cli run --library <dir>/catalog.snap` failed with the OS's "File exists"
(creating a folder over the file). Session::open_library now opens the folder that holds
catalog.snap / catalog.log / catalog.lock, and says "is a file; a library is a folder"
for any other file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:48:36 +11:00
Andrew FrahnandClaude Opus 5.5 48edfb8d3a View: Lights Out (Shift+L) dims or blacks out everything but the photo
View > Lights Out (view.lightsOut) cycles On -> Dim (80 %) -> Off; Esc turns
the lights back on; {mode: on|dim|off} sets a state directly. The panels stay
where they are: a black veil is painted over everything but the loupe's photo
(or the grid / Compare / Survey area). Shift+L, since L is Linear Gradient.

Fixes #611. Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:47:54 +11:00
lloydandClaude Opus 5.5 9815ca1310 Merge upstream/main into fix-export-saturation
Brings the branch up to date with main (#869 shared docking and the docs it
added); no conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:40:04 +02:00
MarekSpodBiedry 2c8520371c Dropping photos with XMP sidecars no longer imports the sidecars as presets (#936) 2026-10-11 11:37:46 +02:00
cookingwithsimonandClaude Opus 5.5 44b12efea3 LR-EXP-PSD: Edit in PhotoCraft (right-click Edit In, flat PSD hand-off)
Photo > Edit In > Edit in PhotoCraft (Cmd+Shift+E, also on the photo
context menu) renders a copy with the edits as a flat 16-bit PSD, which
PhotoCraft saves back to in place, stacks it on the original and opens
it in PhotoCraft, found where its installers put it. Edit in Other
Editor (Cmd+Alt+E) keeps the configured program. Per-editor format,
colour space and bit depth in Settings > External Editing. Export gains
a flat PSD format.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4kqvR9KVCPia3SSXyB8WG
2026-10-11 09:35:43 +00:00
Andrew FrahnandClaude Opus 5.5 5f35b5af17 Cargo.lock: version for lightcraft-lensdb
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:21:42 +11:00
Alex ChambersandClaude Opus 5.5 f05a52968f LR-PROF-CAMERACOLOR: a tone curve from black-and-white camera JPEGs (#577)
A raw shot in a monochrome picture style records full colour, but its
camera JPEG is grey: neither the full look nor the partial look has
colour to fit, so the photo started from the spectral matrices or the
neutral fallback with the default tone curve, about 1.3 EV darker than
the same body's colour-shot photos. The grey JPEG still shows the
camera's tone curve.

When no look was fitted and the JPEG is black and white (`monochrome`),
fit the quantile tone curve alone on top of the colour the photo gets
otherwise: the camera profile's (in `fit_preview`), else the spectral
matrices or the fallback (`monochrome_look`, after the spectral step).
The chroma curve stays neutral; the partial look's checks apply. A
tone-only look keeps the file's colour model for white balance.

LOOK_VERSION 2 -> 3, RENDER_CACHE_VERSION 42 -> 43.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:19:49 +02:00
Andrew FrahnandClaude Opus 5.5 e142075998 NOTICE: credit PhotoCraft for the ported inpaint code; Cargo.lock version for lightcraft-inpaint
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:02:17 +11:00
ingolfteetzandClaude Opus 5.5 ad3c7315bb Tests: hold the MCP export-cancel test's export after its first photo until cancelled (#674)
export_progress_and_cancel failed every time on a fast machine (M5 Max, 4
export lanes): the 8 demo photos exported within the 100 ms progress
throttle, so the first progress above 0 was the final 8/8 and the cancel
arrived after the export had finished (a reply to the cancelled request,
or all 8 files on disk).

The test now wraps Headless in a test-only backend that, at the first
photo boundary, keeps polling the server's real progress hook (which
answers pings and reads cancels) until the cancel is read or the test
releases it, bounded at 20 s so a broken cancel fails rather than hangs.
The first export is released once the mid-batch ping is answered (that
was the same race), the second only ends by the cancel. That makes the
outcome exact, so the check tightens from "1..7 files" to "exactly the
one completed photo", which also proves the photos the other lanes had
already rendered are never written. No production code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:52:59 +02:00
Andrew FrahnandClaude Opus 5.5 bac0f75551 Soft proofing against printer ICC profiles: intent, Simulate Paper & Ink, device gamut warning
Soft Proofing's Profile menu takes a printer / paper ICC profile (Other…;
`view.softProof {profile, intent, paperInk}`), RGB or CMYK, matrix or LUT.
The CMS (moxcms) builds a 33^3 proof table over the working space: forward with
the chosen intent (Perceptual / Relative), back relative colorimetric; the
destination gamut warning comes from the colorimetric round trip. Simulate
Paper & Ink renders absolute colorimetric: the profile's media white replaces
screen white and the ink black is unscaled. The pipeline gets the table by id
(`Proof::profile`, `register_proof_profile`), so Proof stays Copy and render
keys include it. Proof copies are named after the profile.

Sources: ICC.1:2022 (rendering intents, media white point, absolute
colorimetric); own design for the proof table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:46 +11:00
Andrew FrahnandClaude Opus 5.5 fe6d7b0192 Content-aware Remove: inpaint crate (PatchMatch completion) and a Fill spot mode
New `inpaint` crate (L1): multi-scale patch-based completion with a PatchMatch
nearest-neighbour field, seeded by a membrane fill. Deterministic, no panics on
hostile sizes, cancellable. Ported from PhotoCraft (MIT OR Apache-2.0).

Remove tool: a fourth spot mode, `fill` ("Content-Aware"), rebuilds the spot
from patches of its surroundings instead of one source; coarse-to-fine, so the
structure is chosen at a scale set by the spot size and preview and export agree
(tested). spot.add leaves its source unset.

Sources: C. Barnes et al., PatchMatch (SIGGRAPH 2009); Y. Wexler, E. Shechtman,
M. Irani, Space-Time Completion of Video (TPAMI 2007); P. Perez et al., Poisson
Image Editing (SIGGRAPH 2003) for the membrane fill; code ported from PhotoCraft
crates/algo/src/inpaint.rs + poisson.rs (a680ed22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:42 +11:00
Andrew FrahnandClaude Opus 5.5 ab240d6ece Perf: hue/saturation tables skip the sRGB round trip and fmodf in their per-pixel path
HsvTable::apply took the value through linear -> sRGB -> linear even when the table's value
scale is 1 (every hue/saturation-only table, as fitted camera looks are), and wrapped hues with
rem_euclid (an fmodf call) three times per pixel. The value is now kept as it is for a unit
scale and the sRGB-encoded value is reused otherwise; hues are wrapped by a helper that equals
rem_euclid bit for bit (tested) without the call for values within one period.

2048 px render of a 24 MP ARW, single thread: 16.4 -> 15.1 G instructions, 4.94 -> 4.76 G cycles.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:29 +11:00
Andrew FrahnandClaude Opus 5.5 07ae88e923 Perf: preview builds render photos side by side and make the thumbnail from the view's source
library.buildPreviews rendered one photo at a time, and each photo's thumbnail job decoded the
file again at thumbnail level after (before, in fact) the view job had decoded it at preview
level. Now the view renders first and its decoded source serves the thumbnail, and photos
render in as many lanes as an export batch (two for 1:1 previews, a full-size source each);
a panic in one photo counts as that photo's failure.

100 24 MP ARWs, standard previews, loaded machine: 49.4 s -> 38.4 s (CPU 3m28 -> 2m49).

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:29 +11:00
Andrew FrahnandClaude Opus 5.5 6873aab6eb Drafts keep the wide planes across a noise-reduction change (CPU NR drag)
A noise-reduction change invalidated the scene-linear stage and with it every spatial
plane (base, clarity, dehaze, chromaticity), so each tick of an NR drag on the CPU rebuilt
all of them. The plan now also carries the linear key without the NR settings; a draft
render whose image differs from the cached one only in NR keeps those wide blurs and redoes
only log luminance and the fine texture band. Full-quality renders never take them.

Draft NR drag, single thread: prepare 18.7 -> 7 ms (render ~135 -> ~125 ms CPU).

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:28 +11:00
Andrew FrahnandClaude Opus 5.5 db7e37843e Export: Remove location info also covers Original and DNG formats
No location in the XMP sidecar or the DNG's packet; exporting an original
that embeds GPS with the option on is refused instead of leaking it.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:23 +11:00
Andrew FrahnandClaude Opus 5.5 80e58ed55b engine: lens profile corrections from a lensfun-format database
Raws without an embedded lens correction look their EXIF lens up in the
user-supplied database; the profile goes through the existing Enable Profile
Corrections path (amount sliders, CPU/GPU geometry stage, DNG export).
Synthetic-grid test renders a distorted chart through the pipeline.

Sources: own design (see lightcraft-lensdb for the model sources).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:18 +11:00
Andrew FrahnandClaude Opus 5.5 00ea386f7f lensdb: lens distortion (poly3/poly5/PTLens), TCA (linear/poly3) and vignetting (pa) profiles, lensfun-format loader
Profiles are interpolated in focal length (vignetting also in aperture and
focus distance), scaled by crop factor, and converted by least squares to the
DNG WarpRectilinear / FixVignetteRadial polynomials the pipeline already
applies. The lensfun database (CC-BY-SA) is only read from a directory the
user supplies; nothing is bundled.

Sources: lensfun's published database format and lens calibration
documentation (no lensfun code read), PanoTools wiki (a, b, c distortion
model), DNG specification 1.6 (WarpRectilinear, FixVignetteRadial);
interpolation and fitting: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:18 +11:00
PiMinusMaxandClaude Opus 5.5 385348b258 LR-PROF-CAMERACOLOR: the camera-look guard blends in smoothly instead of switching (#820)
Between a kept luminance share of 0.15 and 0.25 the table colour is now blended in with a smoothstep,
so noisy shadows show no step between pixels that get the table colour and pixels that keep theirs.
At 0.25 and above pixels render exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:24:09 +02:00
ingolfteetzandClaude Opus 5.5 4700c8a74e LR-IMP-FORMATS: decode Canon EOS R5 Mark II C-RAW (CRX QP-map first literal and escape)
The CRX 0x200 QP map opens with its first symbol as a 32-bit literal; every
later symbol is Rice coded with a limited-length escape: 23 zeros, the
terminating 1 and the symbol in 8 bits (the ITU-T T.87 layout for a 32-bit
limit and 8-bit symbols, without its minus-one offset). The old rule (an
escape after 16 zeros with a 16-bit literal) matched only the first symbol,
which is all the R8 and R7 maps need. R5 Mark II maps (QP 150-162) jump by up
to 12 where the Rice parameter is 0, giving 18- to 22-zero prefixes that
misparsed ("Canon CRX adaptive QP value -21087"): 174 of 306 sampled files
from a private R5 Mark II library opened as previews. Every QP map of its
33,665 C-RAW files now decodes to the exact end of its entropy stream; the six
oracle fixtures are unchanged. Established by black-box analysis of the
bitstream; no third-party decoder source consulted.

Adds the four CC0 R5 Mark II samples (raw.pixls.us 7881-7884) to the corpus
with pinned sensor hashes (the crop-mode framing is left to #754), bumps
RENDER_CACHE_VERSION to 43, and updates cr3.md, raw-parity (235 verified
models), coverage, gaps, the checklist rows, ROADMAP, target-app-parity and
What's New.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:20:49 +02:00
Alex ChambersandClaude Opus 5.5 484c160c13 LR-EXP-PHOTOS: Apple Photos imports in the activity stack (#345)
Rebased onto the activity stack (#601). An export adding its files to
Photos keeps its row through that step, with "Adding to Apple Photos…" as
the detail. The worker turns the row's ✕ off (TaskHandle::set_cancellable)
before its last look at the cancel flag and reports what it saw, so a
cancel either stops the export before Photos is asked or is refused, never
both "cancelled" and imported. activity: a per-task gate orders a cancel's
decision against a step switching cancelling off, so a cancel can't be
accepted between the switch and the step's look at the flag and then be
ignored. An import of its own (export.addToPhotos, or
the Photos step left running after a foreground app.export) holds a row of
kind applePhotos while Photos works: no ✕, no count, the album as detail.
The desktop app refuses export.addToPhotos {wait: true}: its window's
thread never waits for Photos. The label is in every catalog; tests assert
the rows, the refused cancel and the refused wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:19:00 +02:00
Alex ChambersandClaude Opus 5.5 c722e6bb64 LR-EXP-PHOTOS: Apple Photos fixes from the live test and review
- Ten minutes plus five seconds a file per import (was one minute): the
  first live run timed out while macOS's permission prompt was still up,
  and Photos' duplicates question waits for a person too.
- Files go to osascript 200 at a time, all within the import's one time
  limit: one run with every path went over macOS's 1 MiB argument limit
  at about 10,000 files, and Photos got nothing. A failed later batch says
  how many files Photos had added.
- The progress line "Adding to Apple Photos…" is translated on the UI
  thread; on the export worker it was always English.
- docs/apple-photos.md: who can add what (the control port is reachable by
  any local program, other users' included), batching, the new limit, and
  tested end to end (macOS 26.6.2, Photos 11.0, packaged app). The parity
  row says so; What's New has an entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:19:00 +02:00
Alex ChambersandClaude Opus 5.5 a6431c152d LR-EXP-PHOTOS: add exported files to Apple Photos (macOS, #236)
The Export dialog gets "Add to Apple Photos" with an optional album (macOS
only). app.export takes addToPhotos / photosAlbum (dialog, Export with
Previous, presets, MCP, lightcraft-cli run and render --opt); the new
export.addToPhotos {paths, album?, wait?} adds files already on disk and
export.photosImports {job?} reports how imports went.

A constant AppleScript run by /usr/bin/osascript asks Photos' import
command (Apple Events, not synthetic input) to import the files into a
top-level album of exactly that name (made when missing; albums in folders
are never used; several of that name: refused), skip check duplicates
false, and returns the media item ids. Album names and paths are passed
only as separate arguments (on run argv), never spliced into the script.
The runner is injectable (Session::apple_photos): with_fs installs the real
one on macOS only, and tests use fakes, so no test starts osascript or
Photos.

Imports are jobs (Session::apple_photos_imports), one at a time. The
desktop app (Session::apple_photos_background, set in open_session) never
waits for Photos on its UI thread: the background export adds on its
worker, app.export without background and export.addToPhotos answer with
the running job, and the app announces the outcome. Without a window (MCP,
lightcraft-cli) imports wait by default, and both headless front ends (the
MCP/CLI backend and the snapshot UI) wait for a running one before the
process ends, on every exit path, through one engine helper
(Session::wait_for_photos_imports) whose deadline follows the job: a
writing allowance while an export still writes, then the import's own
time limit from when Photos was asked, under an overall cap. A runner that panics, waiting or on a
worker, ends its import as failed and frees the slot (panic guard plus a
completion guard). Session::after_export reserves
the import slot before an export writes anything and frees it on every
exit path (handed the files, cancelled, failed, dropped), so a competing
import is refused with the export's job number instead of the export
failing its Photos step after writing. Permission (-1743), missing Photos,
cancel, timeouts, ambiguous albums, busy imports, other osascript failures
and partial imports produce actionable messages; the export's files are
kept either way. Hidden on other platforms; the web build compiles it out.

Packaging: the hardened-runtime entitlements gain
com.apple.security.automation.apple-events (app and CLI) and Info.plist
gains NSAppleEventsUsageDescription, without which macOS refuses the
events without asking.

Not yet tested end to end against a real Photos library: manual steps,
with the packaged app, in docs/apple-photos.md. Parity row LR-EXP-PHOTOS is
now partial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:19:00 +02:00
Jason Baldwin 43f31532a7 LRC-LIB-CATALOG-IMPORT: Open Library recognises a Lightroom catalog and offers to import it (#891)
Open Library given a Lightroom Classic catalog folder, one of its .lrdata /
.lrcat-data folders, Backups/, the .lrcat or its -wal/-shm/.lock files used to
create an empty LightCraft library inside Lightroom's folder (or, for the
.lrcat file, close the current library and then fail). Now:

- engine: lightroom_folder::lightroom_catalog_at (names only, top-level scan
  bounded to 10 000 entries, never fails); Session::open_library refuses those
  places with EngineError::LightroomCatalog {path, catalog} before creating
  anything. A LightCraft library (catalog.snap / catalog.log) always wins.
- app: app.openLibrary checks before closing the current library, keeps it,
  returns {opened: false, lightroomCatalog, path} and shows a dialog with
  Import Lightroom Catalog… (file.importLightroom with the catalog found) and
  Cancel. The library problem window gets the error instead.
- Lightroom import status keeps the catalog path after the task ends.
- CLI --library explains how to import instead.
2026-10-11 08:40:18 +02:00
cookingwithsimonandClaude Opus 5.5 5bb43a51f7 LR-VIEW-ZOOM: sharp 1:1 in Compare and Reference (zoom windows per pane)
Compare and Reference drew each pane from a whole-frame render of at most
2560 px, so 1:1 was a magnified preview and focus could not be checked.
Each pane now also renders the window on screen from the photo's own
pixels (Slot::CompareRegion), as the loupe does (#323). The media cache
keeps up to two full-size originals while either view is open, so one
pane's window does not evict the other's source.

Docs: gaps.md entry 4 shrinks to overlays and soft proofing; checklist
LR-VIEW-ZOOM / LR-VIEW-COMPARE and ui-parity zoom row updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UyHnpE9573a7nJFQqnGNex
2026-10-11 05:55:48 +00:00
JordyUSAandClaude Opus 5.5 15f6718a2e LR-HDR-EDIT: read crs:HDREditMode from XMP into hdr.enabled
Lightroom's Light panel "HDR" switch (`crs:HDREditMode`, `1`/`True` on,
`0`/`False` off) now sets `hdr.enabled` when importing XMP sidecars, presets,
DNG presets and Lightroom catalogs, instead of being reported as unmapped (an
off value was already silent). The rest of the HDR settings keep their
defaults. Follow-up to #646, ported from #466.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:47:04 +07:00
JordyUSAandClaude Opus 5.5 7154ee149b LR-EDIT-HDR-MODE: HDR button next to B&W, as Lightroom has it
HDR editing is switched with an HDR button in the Edit panel header, beside
Auto and B&W and drawn the same way (the shared text_button: highlighted
while on), instead of a checkbox at the top of the Light panel. While HDR is
on, the Light panel still shows Visualize HDR and the headroom slider above
the light sliders; with HDR off they're hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:46:28 +07:00
JordyUSAandClaude Opus 5.5 fb678b9e35 LR-VIEW-HISTOGRAM: HDR histogram for HDR edits
An HDR edit's preview shows its SDR rendition, so its histogram said nothing
about the highlights above SDR white. Now it shows the HDR rendition:
`pipeline::hdr_histogram` renders the edit small (384 px, float HDR, same
framing) and bins it with SDR on the left half and the HDR range on the right
(`Histogram::of_hdr`, `HdrBins`), on a fixed scale of Hdr::MAX_EV_LIMIT (5)
stops above SDR white so it doesn't rescale when the headroom limit changes.
The engine's `develop()` (CPU and GPU renders) and the web worker swap it in
for HDR edits; high-bit-depth, windowed and proofed requests keep the image's
histogram, and a display profile's histogram doesn't replace it. Web workers
carry the HDR bins across their wire (`Histogram::to_words` / `from_words`).

The panel draws it in Lightroom's layout: a solid line at SDR white, dashed
lines between the stops, the headroom limit as an orange line with the range
past it dimmed, a bar under the plot (white; one band per stop in Visualize
HDR range's colours while that is on), SDR / HDR labels and an info tooltip.
Follow-up to #646, ported from #466.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:46:27 +07:00
JordyUSAandClaude Opus 5.5 c9c0268f8b LR-HDR-EXPORT: say how viewers without cICP show a PQ PNG
Readers that don't read the PNG cICP chunk show the PQ samples as if they
were sRGB (flat, washed out): expected for PQ PNG, but it surprises people.
Noted in What's new (with the gain map JPEG as the look-right-everywhere
choice), as the export dialog's HDR PNG hint, and in the option and encoder
docs. Review suggestion on #664.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:46:27 +07:00
JordyUSAandClaude Opus 5.5 cbc0e2d7db LR-HDR-EXPORT: HDR PNG export as 16-bit Rec. 2020 PQ (cICP, cLLI)
With HDR output on, a photo edited in HDR exports as a 16-bit PNG with the PQ
curve in Rec. 2020 (SDR white at HDR_REFERENCE_WHITE_NITS), described by a
`cICP` chunk (PNG third edition: BT.2020 primaries, PQ, RGB, full range) and
a `cLLI` content light level chunk (the levels the HDR AVIF records). EXIF,
XMP and print resolution are kept; no ICC profile next to cICP. Photos without
an HDR edit, and HDR edits with HDR output off, export as before.

`codecs::encode_png_pq` sits next to `encode_avif_pq` and takes the same
input. Follow-up to #646, ported from #466.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:46:27 +07:00
Chad ElliottandClaude Opus 5.5 8c84677079 docs(windows): #399 note the pre-0.6 install-folder upgrade fix
Add a What's New entry for keeping the install folder when upgrading from a
pre-0.6 Windows installer, and describe the new Windows MSI job in the
Packaging lint workflow header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:46:21 -05:00
Chad ElliottandClaude Opus 5.5 a82ecb3753 fix(windows): #399 ignore stale App Paths values in the install-folder fallback
The App Paths directory search doesn't check that lightcraft.exe exists, and
when the old installers' unquoted path is gone, Windows Installer falls back
to the text before the first space: a deleted
C:\Program Files\LightCraft\lightcraft.exe resolved to C:\, so an upgrade
would install into the drive root. Gate the fallback on a second search of
the same value with a FileSearch for lightcraft.exe.

The install-folder test gains legacy-folder-deleted (failed before this fix)
and legacy-exe-deleted cases, and removes its empty parent registry key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:18:22 -05:00
Vikram Bhaskaran c536986c48 README: point the font-standard note at craft-fonts (#826).
storytold/craftrules is not a public repository, so the standards link in the
Chinese/Japanese fonts note was a 404 for anyone outside the maintainers. The
rule the sentence states lives with the font repo it points at anyway.
2026-10-11 15:45:40 +11:00
Jason Baldwin c52ff0f014 Merge origin/main into albums-library-photos-only (0.6.0 and shared docking; locale catalogs: kept both sides' keys) 2026-10-11 06:40:00 +02:00
Claude 61fef5cfd5 Progress docs: gaps.md reviewed after 0.6.0 (R7 C-RAW, refused CRX variants, riscv64 build)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXAV8vMWLidoocKuPEpUVk
2026-10-11 04:34:33 +00:00
Nigel Ihza Firdausy 38756b89d0 fix(query): saturate pagination values in 32-bit web builds 2026-10-11 11:04:36 +07:00
Nigel Ihza Firdausy 789019714a fix(sliders): support focused wheel and arrow nudges (#864) 2026-10-11 11:03:13 +07:00
Nigel Ihza Firdausy d9d1a5a730 fix(ui): show completion for library maintenance and Auto Sync (#875) 2026-10-11 10:59:36 +07:00
THEtheChadandGPT-6 Astra 32a358180c fix(windows): #399 preserve legacy MSI install folders
Fall back to the parent directory of the App Paths executable when an
older installer did not write InstallFolder. Keep explicit destinations
and the newer stored folder ahead of the legacy fallback.

Exercise the real MSI searches and actions with isolated per-user probes
on x64 and x86, including legacy paths with spaces and Unicode.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-10-11 03:42:56 +00:00
robmdunnandClaude 21d71feb04 LR-HDR-EXPORT: HDR renders keep the camera tone curve in the midtones
Raws with a fitted camera look (e.g. Nikon NEF) rendered HDR with the
generic tone curve instead, more than a stop darker through the midtones
than the SDR render: PQ JPEG XL / AVIF exports looked dark everywhere,
and gain map JPEGs carried negative gains, so they went dark on HDR
displays. ToneMap::camera_hdr keeps the camera curve up to 1.5 EV over
grey and lifts highlights as ToneMap::hdr does.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 22:15:17 -04:00
sanyamsingh001andCodex 0b61450c7b LR-BEHAV-UNDO: keep file history consistent after failed renames
Co-authored-by: Codex <codex@openai.com>
2026-10-11 12:05:37 +10:00
THEtheChadandGPT-6 Astra aec298c116 test(engine): LRC-LIB-LIBFOLDERS cover mixed Windows separators
Exercise both separator directions for folder and individual-file imports.
Verify duplicate detection, stable photo IDs, added/deleted file detection,
and reuse of a confirmed scan when the folder spelling changes.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-10-11 01:59:03 +00:00
THEtheChadandGPT-6 Astra dac0d4c6e2 test(engine): LRC-LIB-LIBFOLDERS normalize sync fixture paths
Rebuild fixture path components with native separators so expected paths
match directory scans on Windows, including files that have been removed.
This is a prerequisite for Windows validation and overlaps the path-helper
fix in upstream PR #660; it can be dropped once that change lands.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-10-11 01:59:03 +00:00
skylar taylor-barrick 0ba118de92 Merge pull request #869 from skylartaylor/codex/ui-suite-integration-20261010
Add shared docking while preserving photo tools and catalog drops
2026-10-10 19:57:59 -06:00
goujichengandDeepSeek V3.2 bc2cbf41db LR-IMP-PREVIEW-READ: an unedited raw's grid thumbnail is its embedded JPEG
The grid already showed the camera's embedded preview first, then queued a render of the whole raw
to replace it. For a 512 px thumbnail that render decodes 50 of a 56 MB Fuji RAF's bytes for a
picture the 4 416 × 2 944 embedded JPEG already covers — 45 s a photo on the 1.2 MB/s share this was
measured on, 8.3 GB of traffic for a 149-photo folder, and a queue that delayed the render the user
was actually waiting for (measured: the first loupe render of a cold photo finished 174 s after the
click, most of it spent behind thumbnails). Idle for a minute afterwards, the same folder read 12
previews (26.9 MB) and decoded zero raws for thumbnails, against 638 MB of whole-file reads before.

- `Session::embedded_thumb_is_final` answers whether a photo is an unedited raw whose camera JPEG the
  viewer is showing — the same `has_import_look` gate the preview path uses.
- `grid::skips_the_real_render` is the decision (unit-tested): a cached render as stand-in, an edited
  photo and a raw without an embedded JPEG all still render, because their thumbnail must show the
  pipeline's output rather than the camera's.
- `Renderer::remember_embedded_thumb` records the quick render's key and the bucket, and
  `thumb_quick_is_final` / `thumb_quick_pending` let the per-frame grid request stop at the embedded
  preview instead of rebuilding the render job (and its cache key) every frame.
- The thumbnail key still follows the settings hash, so editing a photo re-renders its thumbnail
  through the ordinary background path.

`docs/raw-preview-reads.md` and the `LR-IMP-PREVIEW-READ` parity row record the behaviour and the
numbers.

Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
2026-10-11 09:39:32 +08:00
robmdunnandClaude 77fcd81dde Merge main into jxl-export
ROADMAP.md: take main's restructured version; JPEG XL export is now
recorded in docs/file-format-parity.md and docs/gaps.md (gap 25).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:32:39 -04:00
skylar taylor-barrick a7e8f1cc26 UI.9: reconcile upstream before shared UI landing 2026-10-10 19:07:51 -06:00
PiMinusMaxandClaude Opus 5.5 d76a4121e6 LR-PROF-CAMERACOLOR: near-black pixels no longer turn white in the camera look (#820)
HueSat::apply restores luminance with before / after. Near black, where noise makes some channels
negative, the hue/saturation table can bring after close to 0, and the gain turned single pixels
white (isolated white dots in dark areas, most visible at fit). Luminance is now restored only if
the table kept at least 25 % of it; such colours keep their colour, like black and non-finite ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:32:34 +02:00
john01px2022andClaude Opus 5.5 32e78b352c LR-EXP-WATERMARK: the watermark preview fills the Export dialog's width
The preview was capped at 348 × 200 pt, so a portrait photo showed about
130 pt wide. It now spans the dialog for any photo shape. The cached 512 px
thumbnail still shows at once; a render at the preview's own pixel size
(in 128 px steps) then replaces it, so the wider preview stays sharp. The
web build, which has no worker thread, keeps the thumbnail only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:31:56 -07:00
john01px2022andClaude Opus 5.5 7d27cf8bfb LR-CROP-STRAIGHTEN: test the rotate pointer off all four crop corners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:18:57 -07:00
john01px2022andClaude Opus 5.5 6807421f64 LR-CROP-STRAIGHTEN: the rotate pointer faces away from its side of the crop box
The crop tool's rotate pointer always bulged up, which only reads right
above the box. It now turns to bulge away from the nearest point of the box
outline: right beside the right edge, down below it, left beside the left
edge, diagonally off a corner, and following a box that is already rotated.
icons::paint_turned draws an icon turned about its centre; icons::paint is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:09:44 -07:00
radialmonster f032d6e389 LR-PROF-CAMERACOLOR: read an untagged camera JPEG in the colour space the raw's EXIF states, for every make (#778)
A Sony body shot in Adobe RGB writes ColorSpace Uncalibrated with InteropIndex R03 in the raw's EXIF and leaves the
embedded JPEG untagged, so the look was fitted against the JPEG decoded as sRGB. The EXIF fallback now applies to
all makes (the Nikon maker note still wins); DNGs are skipped, since their previews come from the converter.
2026-10-10 20:06:50 -04:00
Brandon ThomasandClaude Opus 5.5 9e6c4c6fc9 Release prep: 0.6.0 and Linux riscv64 release build (#844)
- release.yml: new linux-riscv64 job (ported from GridCraft): cross-compiles on
  ubuntu-24.04 against ports.ubuntu.com riscv64 libs, packages a tar.gz with
  package.sh --formats tar, smoke-tests lightcraft-cli under QEMU. The release
  job needs it, so its tarball lands in the draft and SHA256SUMS.
- packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR
  support (target dir, cross strip, emulated --version).
- README Downloads and docs/releasing.md list the riscv64 tarball.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:38:10 -04:00
john01px2022andClaude Opus 5.5 6f44b33ca7 LR-EXP-WATERMARK: rotate the watermark in quarter turns (#827)
A watermark (text, horizontal or vertical, or a graphic) can be turned 0,
90, 180 or 270 degrees clockwise, like Lightroom Classic's rotate buttons.
It is sized before it is turned and placed and inset by the turned box; the
shadow still falls down and to the right. New watermark key `rotation`
(other values are errors); settings without it draw unturned. The Export
dialog gets a Rotate row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:18:57 -07:00
john01px2022andClaude Opus 5.5 ffd737ceab LR-EXP-WATERMARK: separate horizontal and vertical watermark insets (#827)
Watermark.inset becomes inset_x (from the left or right edge) and inset_y
(from the top or bottom edge), like Lightroom Classic's watermark editor.
The watermark keys are insetX and insetY; the old inset key still reads as
both, so saved presets and existing MCP calls keep working.

The Export dialog's Inset slider becomes Horizontal inset and Vertical
inset, each greyed out when the position is centred along its axis.
cargo fmt reflows WM_POSITIONS and its test from the previous commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:11:03 -07:00
john01px2022andClaude Opus 5.5 aa6054cd1a LR-EXP-WATERMARK: preview the watermark in the Export dialog (#827)
With Watermark on, the Export dialog shows the first photo it will export
(its cached thumbnail) with the watermark drawn as the file will have it.
The watermark is redrawn on a copy only when it or the output size changes.

draw_watermark_preview scales the 6 px minimum text size and the 1 px
minimum shadow offset by the preview size over the export size, so a small
watermark doesn't look bigger in the preview than in the file. Exports draw
with a scale of 1 and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:58:53 -07:00
skylar taylor-barrick 9bfabc98b1 UI.9: integrate shared docking and preserve panel interactions 2026-10-10 16:22:21 -06:00
PiMinusMaxandClaude Opus 5.5 40d7b86ecb LR-VIEW-BEFOREAFTER: click tools work in the Before view of side by side / top-bottom
In the side-by-side and top/bottom Before/After layouts a click in the
Before view was mapped to image coordinates through the After view's
rectangle, so the White Balance Selector sampled the wrong point (at Fit,
the photo's edge). The click tools in general_interaction now use the
Before view's image rectangle when the pointer is in the Before view's
pane; the pane decides, since zoomed in an image reaches past its pane.

New headless test tests_before_after.rs clicks the same image point in
the After and Before views (side by side, top/bottom; Fit and 100 %) and
expects the same white balance. Without the fix: Before 8202 K / +116,
After 2710 K / +33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:11:49 +02:00
radialmonster 8f3f168802 LR-META-EXIF-FULL: raw sensor IFD Compression 7 reads Lossless JPEG (#830) 2026-10-10 17:59:11 -04:00
Nigel Ihza Firdausy a66a133041 test(previews): cover legacy smart preview discovery and nonblocking look refresh 2026-10-11 04:54:04 +07:00
Nigel Ihza Firdausy e90d83ecc3 fix(previews): shard smart preview storage and preserve flat layout compatibility (#795) 2026-10-11 04:53:07 +07:00
PiMinusMaxandClaude Opus 5.5 a67d571656 LR-VIEW-COMPARE: a filmstrip click shows the photo in Compare
In Compare a filmstrip click made the clicked photo active without
showing it, so the Edit panel and the rating keys acted on a photo on
neither side (a headless test rated the hidden photo 2 -> 3 stars). The
click now puts the photo into the active side (the candidate after
entering Compare, the select after clicking the select's photo); a photo
already on the other side swaps the two. The active photo is always one
of the two shown. Other views keep the usual selection clicks.

New UI command compare.set {id, side?: "select" | "candidate"} for the
same through the control channel and MCP.

New headless tests in tests_compare.rs: the active photo stays on screen
and no hidden photo is rated (Fit and 100 %), and replace / swap /
explicit side / unknown side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:51:43 +02:00
radialmonster 7ce13cfd07 LR-META-EXIF-FULL: All Metadata shows the raw image's Compression, NEFCompression in words (#830) 2026-10-10 17:51:27 -04:00
Nigel Ihza Firdausy ba7df9c7fe fix(export): allow adjusting watermark margin in the export dialog 2026-10-11 04:48:33 +07:00
Nigel Ihza Firdausy bbedde35cb fix(export): expose all nine watermark positions (#827) 2026-10-11 04:47:36 +07:00
eliets2 a940dab727 web: stop the release zip shipping Cargo build artifacts
lightcraft-web-<version>.zip carried Cargo host build output next to the site: deps/ (81 files, 90.6 MB), build/ (149 files, 22.4 MB) and .fingerprint/ (297 files), ~113 MB that the app never references and that leaked build paths and crate names to anyone hosting the zip.

Cause: xtask wrote the bundle to <target>/web (SITE in packaging/web/package.sh) while building with --profile web. Cross-compiling with --target wasm32-unknown-unknown makes Cargo write the selected profile host artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/) to <target>/<profile>/, so they landed in the bundle directory and package.sh copied the whole tree into the zip.

Fix: write the bundle to <target>/web-site, a directory that is not a Cargo profile name, and point SITE at it. The wasm32/web/ target artifacts and the web profile are unchanged, so the rust-cache keys and the CI web job keep working.

Guard: package.sh now fails if deps/, build/ or .fingerprint/ exists in $SITE, and a unit test asserts the bundle directory is not a Cargo profile name, so the collision cannot come back silently.

Reference (#793)
2026-10-11 00:46:10 +03:00
PiMinusMaxandClaude Opus 5.5 8e9c5aff7e LR-EDIT-COLOR-WB-PICKER: the selector button does what W does
The White Balance Selector button in the Edit panel only armed the tool,
so in Compare (which doesn't handle on-image tools) it did nothing while
W opened the photo in Detail with the selector armed. The button now runs
tool.wbPicker like W; a second click still disarms it.

New headless test tests_wb_selector.rs clicks the button in Compare and
expects Detail with the selector armed on the photo that was active; it
fails without the fix (the view stays Compare).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:32:08 +02:00
Sajidur RahmanandClaude Opus 5.5 d6cfab8223 LR-EDIT-COLOR-GRADING: grading tabs, per-wheel eye, precision guides (#737, #559)
Color Grading gets 3-Way / Shadows / Midtones / Highlights tabs (3-Way by
default). 3-Way shows three equal wheels; a range tab shows one large wheel
with that range's Hue, Saturation and Luminance sliders. Blending and Balance
are in every tab.

Precision (#559):
- Shift-drag on a wheel locks the hue and changes saturation only; the whole
  spoke is drawn as a guide. Shift can be pressed or released mid-drag.
- A white dot marks each wheel's neutral centre.
- The large wheel has dashed 25/50/75 % saturation rings.
- 3-Way shows the hovered wheel's hue and saturation above it.

Each wheel has an eye (bottom-left, on hover) that hides its effect for
viewing only: RenderJob::with_hidden_grading renders those wheels neutral
under their own key, never cached as the photo's preview. Not an edit, not
saved, reset on another photo; export is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:24:09 +06:00
radialmonster 5a975d0d75 LR-IMP-FORMATS: CR3 CRX quantization 44 (QP 168) decodes, continuing the step sequence
Canon EOS R50 C-RAW files reach QP 168 (quant 44, step 102, measured about 100). The quantization step and the adaptive QP map range accept it; anything above stays rejected. Bumps RENDER_CACHE_VERSION since such files change from the embedded preview to a raw decode.
2026-10-10 17:20:31 -04:00
PiMinusMaxandClaude Opus 5.5 0067879a58 KEY-ESC: Esc leaves the crop tool before leaving Detail, and a text field's Esc stays its own
view.back (Esc) closed dialogs, the keyword painter, full screen and an
armed on-image tool, but not the crop tool (a right panel, not
app.ui.tool), so Esc with the crop tool open went straight to the grid.
It now closes the crop tool like C does (the crop stays); the next Esc
goes back to the grid.

Esc that ends an edit in a text field (the crop tool's Angle field) also
ran Back: egui drops the field's focus as Esc arrives, before
shortcuts::handle checks egui_wants_keyboard_input(). The app now
records whether a text field had the keyboard at the end of the last
frame (LightcraftApp::text_focus); that Esc is then left to the field,
like one that closes a popup.

New headless tests in tests_crop_tool.rs; both fail without the fix
(the existing escape_in_the_angle_field_keeps_the_angle fails with only
the crop step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:15:13 +02:00
PiMinusMaxandClaude Opus 5.5 0adcb21166 LR-CROP-FLIP: Flip H / Flip V show in the crop tool
The frame dropped the flips together with the crop (flip_h: apply_crop &&
...), so the crop tool's uncropped view (render and screen <-> image
mapping) ignored Flip H / Flip V until the tool was closed. A flip isn't
part of the crop: the frame now keeps it without the crop. Cropped
renders and exports are unchanged.

The crop overlay's dimming paired the photo's corners with the crop
box's corners in data order; flipped, the box reaches the screen
mirrored and the photo was dimmed inside the box. It now takes the box's
corners in screen order (flipped_corners). Box, handles, drags and guides
already go through the frame's mapping.

Tests: geometry uncropped_frame_keeps_the_flips (fails without the fix)
and detail flipped_crop_box_corners_come_in_screen_order; headless
screenshots for H, V and H+V checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:03:29 +02:00
Wondarkus 93d76a24db Merge branch 'storytold:main' into main 2026-10-11 00:56:48 +04:00
Wondarkus 22a836316d ru: sync with uk (810 new strings) 2026-10-11 00:55:44 +04:00
akvaithiandClaude Opus 5.5 88f424e7e7 LR-EDIT-DETAIL-DENOISE: share tiles with a server only when it is faster
Measured with RawNIND on a 20.8 MP raw: an Apple M3 does a tile in 116 ms, an
RTX 3060 Ti over Tailscale 345 ms there and back; sharing blindly made the
export take 34 s instead of 6.4 s while the photo waited for the server's last
tiles. The greeting now times a check tile there and back, and tiles are
shared only when that beats this computer's own time per tile (the card's, or
the CPU's with its threads counted as `card_is_faster` does). Otherwise
`denoise.status` → `device.remote` says `slower` with both times. With the
server configured but slower the export takes 7.2 s (greeting + check tile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:53:39 -05:00
PiMinusMaxandClaude Opus 5.5 bc5c561dd8 LR-VIEW-BEFOREAFTER: zoomed side by side / stacked views stay in their panes
Zoomed in, each view's photo reaches past its pane. The whole-frame renders
were drawn unclipped (only the window renders were clipped to their pane),
so the After's magnified whole-frame render covered the Before pane's margins
and the gap between the panes with a blurred picture, and the Before side
looked like it didn't fill its pane. Both whole-frame renders are now clipped
to their pane, like the windows over them; the Before / After labels stay
under the pane instead of moving off screen with the photo's bottom edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:47:43 +02:00
Adrian D'souza 5855ab80a6 XMP.434: reconcile current main & complete warning locales
Preserve Target-aware sidecar operations, add French/Ukrainian warnings, and ensure omitted diagnostic fields decode in the native UI.

Generated with Codex.
2026-10-11 02:11:00 +05:30
akvaithiandClaude Opus 5.5 84b8995d7b LR-EDIT-DETAIL-DENOISE: run tiles on another computer's graphics card
- `lightcraft_denoise::remote`: protocol v1 over TCP (JSON lines + byte-shuffled,
  deflated half-float tiles), a `Remote` client (connection pool, four slots,
  greeting checks the model's SHA-256 and tile size, gives up after three failed
  tiles) and `serve` (shared token compared in constant time, size caps including
  payloads that inflate past their tile, connection limit, timeouts).
- Engine: with LIGHTCRAFT_DENOISE_URL / LIGHTCRAFT_DENOISE_TOKEN set, the loaded
  model greets the server on first use and adds its slots to this computer's
  tiles at once; a tile goes to a free server slot, else runs here, and a tile
  the server fails runs here too. `denoise.status` → `device.remote` says where.
- `lightcraft-cli denoise-serve` serves the installed, selected model on this
  computer's card (checked against the CPU, as in the app) or CPU.
- docs/denoise.md: setup, safety (unencrypted: VPN/LAN only) and the protocol.

Tested: protocol round trips, refusals (token, model, tile size, hostile
payloads), fallback and give-up; the engine model with a live, a mismatched
and a dead server; the CLI binary serving a synthetic model end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:21:48 -05:00
Lucas LepskiandGPT-6.1 Sol fe92e844d0 UI-PILL: keep rating and flag buttons inside their pill
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
2026-10-10 22:06:52 +02:00
Lucas LepskiandGPT-6.1 Sol e1267a7139 UI-MODAL: block background input while application dialogs are open
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
2026-10-10 21:55:57 +02:00
Jason Baldwin a3c33658bb Merge origin/main into brush-hold-hardens (clean merge; tracker edit followed the parity-checklist.md rename) 2026-10-10 21:49:33 +02:00
Jason Baldwin 7216c8589a LR-EDIT-COLOR-TEMP: raw-parity.md describes Kelvin WB for matrix cameras (#730) 2026-10-10 21:47:36 +02:00
Jason Baldwin 45227de921 Merge origin/main into kelvin-wb-with-matrices (no conflicts; docs/parity.md rename followed cleanly) 2026-10-10 21:46:44 +02:00
Jason Baldwin 0fdf6c5785 Merge remote-tracking branch 'origin/main' into brush-live-preview-gaps 2026-10-10 21:44:40 +02:00
Jason Baldwin 78c5b4d099 Merge origin/main into crop-angle-constrained 2026-10-10 21:39:04 +02:00
Jason Baldwin 62dacf2791 Merge origin/main into crop-rotation-artefact 2026-10-10 21:37:05 +02:00
Jason Baldwin 7a232ada8b Merge remote-tracking branch 'origin/main' into nef-probe-header-only 2026-10-10 21:37:02 +02:00
Jason Baldwin 7765255ec6 Merge follow-up: whats-new entry under 2026-10-10 2026-10-10 21:36:32 +02:00
Jason Baldwin de6b33c662 Merge remote-tracking branch 'origin/main' into reimport-after-delete 2026-10-10 21:33:45 +02:00
Jason Baldwin b4d7cd1a03 Merge remote-tracking branch 'origin/main' into gpu-fallback-per-render 2026-10-10 21:33:40 +02:00
Jason Baldwin 89516d2e3e Merge origin/main into lock-unlock-on-drop (whats-new entry moved under 2026-10-10) 2026-10-10 21:32:55 +02:00
Jason Baldwin e7076762c0 Merge remote-tracking branch 'origin/main' into sony-arw-washed-out 2026-10-10 21:31:53 +02:00
Jason Baldwin bf4a271a71 Merge remote-tracking branch 'origin/main' into lock-unlock-on-drop 2026-10-10 21:31:51 +02:00
Jason Baldwin be4cbebbd3 LR-PREF-PEOPLE: restore 'faces' typo in the tracker row after the merge 2026-10-10 21:27:27 +02:00
Jason Baldwin 2c2f28952f Merge origin/main into menu-scroll-clips-shortcuts (docs restructure; no conflicts) 2026-10-10 21:18:32 +02:00
Jason Baldwin 85b6fdaedd Merge remote-tracking branch 'origin/main' into faces-toggle-needs-a-model 2026-10-10 21:18:31 +02:00
Jason Baldwin 6ae35b0a5b Merge remote-tracking branch 'origin/main' into albums-library-photos-only 2026-10-10 21:18:30 +02:00
Jason Baldwin 46ff7bcf4e Merge remote-tracking branch 'origin/main' into grid-add-to-album-new-album 2026-10-10 21:18:25 +02:00
Jason Baldwin 4dd6fb5089 Merge origin/main into uppercase-extensions (whats-new: keep main's entries, re-add the Formats bullet under 2026-10-10) 2026-10-10 21:16:49 +02:00
Jason Baldwin dd73753723 Merge origin/main into info-dialogs-close (no conflicts; MENU-HELP-SYSINFO note now in docs/parity-checklist.md) 2026-10-10 21:16:30 +02:00
Jason Baldwin 179941f4ff Merge origin/main into dialogs-fit-the-window (re-applied LR-BEHAV-DIALOGS row and LR-EXP-DIALOG note onto docs/parity-checklist.md, regenerated summary) 2026-10-10 21:15:37 +02:00
PierrunoYTandClaude Sonnet 5.5 3cd471b32d Smooth the plain mouse-wheel zoom like Ctrl+wheel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 21:08:25 +02:00
dev-null-undefinedandClaude 4860f5e8d4 LR-IMP-FORMATS: ARW6 companding curve derived clean-room from CC0 lossless pairs
The 2668-entry table read off the black-box oracle's output is gone.
arw6_curve.rs now builds the 4096-entry table at first use from our own
measurement: piecewise linear through knots u_k = 128 k, c_k = u_k for
k <= 11 and 356.89 + 655.646 ln(k - 6) above, rounded half up, fitted to
the 7 same-exposure lossless (LJ92) / ARW6 pairs of the CC0 ILCE-7RM6 set.
A unit test pins the whole table (sum 27776336, weighted 92792980288),
its anchors, the identity through 1408 and strict monotonicity.

The corpus sums become regression values of the new output; the 12-bit
codes before the curve stay verified against the oracle (Phase 0).
docs/arw6-compression.md describes the curve, how it was measured, its
uncertainty, the structure and x2-scale evidence, why it is not in the
file, and the after-the-fact comparison with the oracle's curve; the
tracker and docs no longer call the output bit-exact against LibRaw.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefinedandClaude 4d26a4d7b4 LR-IMP-FORMATS: render cache version 43 (ARW6 decodes instead of falling back to the embedded JPEG)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefinedandClaude 2218a53f2b LR-IMP-FORMATS: ARW6 module docs cite docs/arw6-compression.md
docs/superpowers/ held the working spec and plan, which pointed at the
local plan/ folder. The ARW6 module docs now cite the sections of
docs/arw6-compression.md, which gains the facts they relied on from the
spec: the NP field of word 0, the stream header word layout and how the
first half's residual lines are counted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefinedandClaude 2f75b21ac3 LR-IMP-FORMATS: ARW6 corpus pins two files (FF Compressed, APS-C HQ)
`cargo xtask corpus --download` now fetches one full-frame Compressed and
one APS-C Compressed HQ file (about 94 MB) instead of all 12 (about
630 MB); the corpus test checks those two and the lossless-sibling
regression test is gone, since nothing would download those files.
docs/arw6-compression.md keeps the full table with source paths and
SHA-256, says which two xtask fetches and how to fetch the rest with
`curl -L`, and records the CC0 1.0 statement of RawDB's set record
(accessed 2026-10-10).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefinedandClaude 1be303ca00 LR-IMP-FORMATS: ARW6 corpus hashes live in RAW_SAMPLES; drop docs/arw6-corpus.sha256
The RawDB entries in xtask carry their SHA-256 as the third field, which
`cargo xtask corpus --download` checks; the corpus test keeps only the
sensor sum checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefined fa7f1e8d6c LR-IMP-FORMATS: ARW6 docs after the final review 2026-10-10 21:08:17 +02:00
dev-null-undefined 69d3d4cf91 LR-IMP-FORMATS: ARW6 chroma clamps negative greens; 12-bit guard; fallback white 2026-10-10 21:08:17 +02:00
dev-null-undefinedandClaude aea5236790 LR-IMP-FORMATS: ARW6 decode timing method
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefinedandClaude 9aa9bcb54e LR-IMP-FORMATS: ARW6 decode timings
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 21:08:17 +02:00
dev-null-undefined 458b938248 LR-IMP-FORMATS: ARW6 corpus verification, docs and tracker 2026-10-10 21:08:16 +02:00
Jason Baldwin 1064e9847d Merge origin/main into enter-applies-copy-paste (parity rows applied to docs/parity-checklist.md) 2026-10-10 21:07:39 +02:00
dev-null-undefined 7b4bdfa68b LR-IMP-FORMATS: ARW6 four-tile placement test checks every tile 2026-10-10 21:07:30 +02:00
dev-null-undefined 1a146c12b0 LR-IMP-FORMATS: ARW6 chroma planes are base-free; white fallback not doubled 2026-10-10 21:07:30 +02:00
dev-null-undefined 1bbb57650c LR-IMP-FORMATS: ARW6 halves must end within their last byte 2026-10-10 21:07:30 +02:00
dev-null-undefined 15397a712e LR-IMP-FORMATS: ARW6 colour reconstruction, companding table and ARW dispatch 2026-10-10 21:07:30 +02:00
dev-null-undefined 6cba574990 LR-IMP-FORMATS: ARW6 sensor-anchored frame geometry and tile reconstruction 2026-10-10 21:07:30 +02:00
dev-null-undefined e45c4625b4 LR-IMP-FORMATS: ARW6 container rejection tests and tile byte ranges 2026-10-10 21:07:29 +02:00
dev-null-undefined f101a09142 LR-IMP-FORMATS: ARW6 tile table, tile and stream headers; synthetic ARW6 encoder for tests 2026-10-10 21:07:29 +02:00
dev-null-undefined 868ac707dd LR-IMP-FORMATS: ARW6 lifting arithmetic cannot overflow on hostile coefficients 2026-10-10 21:07:29 +02:00
dev-null-undefined 3a4a70fa8f LR-IMP-FORMATS: ARW6 parity dequantiser and 5/3 inverse lifting with phases 2026-10-10 21:07:29 +02:00
dev-null-undefined 01438b8979 LR-IMP-FORMATS: ARW6 VLD returns an empty line for width 0 2026-10-10 21:07:29 +02:00
dev-null-undefined 083685a34d LR-IMP-FORMATS: RDD 34 VLD line decoder with Sony's ARW6 deviations 2026-10-10 21:07:29 +02:00
PierrunoYTandClaude Sonnet 5.5 732a2f04fd Test: wheel zoom in the loupe with and without Ctrl
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:59:29 +02:00
PierrunoYTandClaude Sonnet 5.5 7f966965fc Fix #714: mouse wheel zooms the loupe around the pointer
A notched wheel (line/page units, no modifiers) zoomed nothing; only
double-click and pinch/Ctrl+wheel did. Trackpad scrolls still pan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:56:23 +02:00
Steffen SauderandClaude Sonnet 5.5 e363d298d9 LR-IMP-FORMATS: point to the two Olympus patents as a clean-room base for compressed ORF
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:51:23 +02:00
Brandon ThomasandClaude Opus 5.5 964d9338ba Progress docs: target-app parity, RAW coverage vs Adobe, gaps, alpha gate, readiness by audience (#788)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, model-by-model RAW coverage against Adobe's camera list (docs/raw/), and format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:25:15 -04:00
radialmonster 0041aef955 LR-EDIT-OPTICS-PROFILE: apply DNG WarpRectilinear2 lens corrections (Pixel DNGs, #635) 2026-10-10 14:21:13 -04:00
radialmonster 9da719c6ed LR-EDIT-RESET: Reset Edits returns a raw to its import defaults, sharpening and colour NR included
Reset Edits rebuilt a photo without a default-preset look from DevelopSettings::default(),
so a raw imported with capture sharpening 40 and colour noise reduction 25
(DevelopSettings::for_raw) came back with both at 0, and with its embedded lens
corrections off. It now starts from the photo's own built-in defaults
(Photo::camera_defaults, the same as import) and keeps the white balance of its
current as-shot reference, as before. Rendered files are unchanged (their defaults
are DevelopSettings::default()). Reported in #763's notes.
2026-10-10 14:01:26 -04:00
lloydandClaude Opus 5.5 d7eb6b2ab4 LR-PREF-DISPLAY: macOS previews follow the screen's profile, so they match exports (#787)
Previews looked more saturated than the exported files on Mac screens. The
pixels are the same: preview and export match to within one level on the CPU
and GPU paths, and exports embed a correct sRGB profile. But wgpu leaves the
window's Metal layer without a colour space for sRGB surfaces, and Core
Animation shows such a layer without colour matching ("If nil, no
colormatching occurs", CAMetalLayer.h). On a P3 screen the sRGB previews
were therefore shown too saturated, while Preview and Photos convert the
exports properly.

While no monitor profile is chosen in Settings > Display, the desktop app now
shows previews through the profile of the screen the window is on
(NSWindow.screen.colorSpace.ICCProfileData, safe objc2 calls), through the
existing display-profile path. It is read again about once a second, so
moving the window to another screen is followed. A chosen profile still wins;
"Use Screen Profile" goes back to it. app.displayProfile reports it as
path: null, screen: true. Other platforms and headless sessions are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:51:01 +02:00
radialmonster 70103a7e55 LR-EDIT-LIGHT-HIGHLIGHTS: keep local contrast under Highlights/Shadows, as process V2 (#632)
The highlights/shadows base (fast guided filter of log luminance, eps 0.35)
followed thin high-contrast detail, so the per-pixel shift flattened twigs,
lines and texture in bright areas (and dark ones under Shadows); Exposure
kept them.

This changes how every existing edit with Highlights or Shadows renders, so
it ships as a new rendering process (docs/process-versions.md):

- Process::V2 (version 2) is added and made LATEST. New photos and Reset get
  it; existing edits stay on V1 until Update to Current Process. Settings
  saved without the field still load as V1.
- From V2 the base is the same guided filter of a separable median of log
  luminance (radius 0.15 % of the long edge, <= 24 px): fine detail takes its
  surroundings' shift and keeps its contrast, region steps stay put (no new
  halos). V1's base is untouched: local::tone_base_detail is 0 under V1 and
  tone_base then is exactly guided_fast(l, sigma, BASE_EPS), as before.
- CPU (local::prepare) and GPU prepare (median_h / median_v kernels, bit for
  bit with the CPU median) both branch on it via PlaneSigmas::base_detail.
- The base plane's cache key is now (sigma, process, median radius)
  (local::BaseKey), so a warm cache never serves one process the other's
  base. plane_at is shared with the GPU crate.
- finish::base_tone: V2 uses V1's tone map.

Tests: V2 keeps twig/local contrast (>= 0.9 of Exposure's) with no halo; V1
plane sigmas have no median and its base equals guided_fast bit for bit; V1
and V2 render alike without Highlights/Shadows; warm-cache renders across
V1/V2 switches equal fresh ones (fails if the key drops the process); GPU
matches CPU on both processes at 720 and 2400 px, cached and not.

Pentax 645D 2401 at 4000 px: main and V1 renders bit-identical (plain,
Highlights -100, Shadows +100). Highlights -100 bright rel. detail
0.095 (V1) -> 0.147 (V2), Exposure -1: 0.124; Shadows +100 dark rel. detail
0.126 -> 0.148.

RENDER_CACHE_VERSION 54 (adjust at merge).
2026-10-10 13:46:14 -04:00
Nathaniel LedfordandClaude Opus 5.5 02a21b860d LR-BEHAV-SIDEBAR-SECTIONS: review fixes for the header button and the pinned header
A keyword made from Keywords' + is shown (the section and its parents open); a
press on a header button never starts a section drag; a section dragged to the
top edge scrolls past a pinned header again (only a drag picked up by the
pinned header waits until it has left it). Tests that could not fail are
replaced, and the parity rows say what the code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:14:07 -04:00
Nathaniel LedfordandClaude Opus 5.5 d331829770 LR-BEHAV-SIDEBAR-SECTIONS: a section declares its header button; Keywords gets + (Create Keyword Tag)
Albums' + is no longer a special case of the header: header_button says which
section has a button and whether it acts or opens a menu, and the frame draws
it in the header and its pinned copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:04:02 -04:00
Jason Baldwin bf49935fe7 LR-BEHAV-DIALOGS: dialogs fit the window; options scroll, title and buttons stay visible (#781)
Every dialog but Import draws its options in a vertical scroll area capped at the
window height minus the title bar, frame and button row (measured on the previous
frame), so on a 1366x768 screen the Export dialog's title and Cancel / OK stay on
screen. Dialogs that fit keep their size. Lists that scroll on their own (What's
New, All Metadata, shortcuts, smart-album rules, credits) get shorter first rather
than scrolling inside a scrolling dialog. Import keeps its resizable window.
2026-10-10 18:57:37 +02:00
Nathaniel LedfordandClaude Opus 5.5 9cd9e29614 LR-BEHAV-SIDEBAR-STICKY: rows beneath the pinned header are not drop targets
An album or photos released on the pinned Albums header went to the row
scrolled under it. Folding from the pinned header keeps the sideways scroll,
its hand-off to the next frame can't go stale, and a section picked up by the
pinned header doesn't start scrolling at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:51:55 -04:00
Jason Baldwin 19833e56e3 MENU-HELP-SYSINFO: System Info, All Metadata and What's New end with one Close button (#782)
Their confirm does nothing, so they join About, Shortcuts and Settings in the
footer's informational list: one Close, no Cancel / OK. System Info keeps Copy
to Clipboard; Esc still closes them.
2026-10-10 18:50:12 +02:00
Nathaniel LedfordandClaude Opus 5.5 5e68d92c9f LR-BEHAV-SIDEBAR-STICKY: a long section's header stays in sight; sections are easier to tell apart
Pinned header at the top of the sidebar, hairlines between sections, the fold
chevron in the rows' triangle column, a count on folded sections, fainter zero
counts. From review: a move nobody could see is no move, ui.set refuses
`sidebar` together with `collapsedSidebar` and unknown names in the latter.
Translations and What's New for the sidebar work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:44:46 -04:00
Nathaniel LedfordandClaude Opus 5.5 5ac3e556ac LR-BEHAV-SIDEBAR-REORDER: drag sidebar sections by their header, Move Up / Down, Recently Deleted joins My Photos
view.sidebarMoveSection; a section with nothing to list stays with a note
instead of disappearing (so every move is one you can see). ui.set reads
`sidebar` strictly and still takes the older collapsedSidebar list; an album
folder's triangle and row can no longer toggle twice in one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:36:13 -04:00
Nathaniel LedfordandClaude Opus 5.5 a64efb149c LR-BEHAV-SIDEBAR-HIDE: show and hide sidebar sections from View ▸ Sidebar Sections and their headers
view.sidebarSection / view.sidebarReset; the same list opens from a section
header's and the My Photos title's context menu. A new album brings a hidden
Albums section back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:29:28 -04:00
Nathaniel LedfordandClaude Opus 5.5 338b67ffbe LR-BEHAV-SIDEBAR-SECTIONS: sidebar sections share one frame, one tree toggle and one saved arrangement
Albums, Local, By Date, Folders and Keywords are drawn by one section frame from
SidebarLayout (order, hidden, collapsed per section), which is whole whatever a
settings file says. The folded sections of older settings files stay folded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:26:29 -04:00
robmdunnandClaude Opus 5.5 afbc0f9744 LR-EXP-TYPE: JPEG XL export (#247)
Export as JPEG XL from the dialog, CLI, MCP and control channel (format jxl): lossy with
quality or lossless, 8/16-bit, effort fast/normal, progressive, EXIF/XMP boxes; HDR edits
as 16-bit Rec. 2020 PQ. Encoder: jixel (pure Rust, BSD-3-Clause OR Apache-2.0, native
only: it spawns its own threads), wrapped in catch_unwind. Lossy files declare an enum
encoding (sRGB / P3 / Rec. 2020; Adobe RGB and ProPhoto written as Rec. 2020); lossless
files embed the ICC profile.

Decoder fix: jxl-oxide desaturated colours outside sRGB when rendering XYB images to
wider primaries; lossy wide-gamut JXL now renders to linear sRGB primaries and is
converted to Rec. 2020 here. jixel's slowest effort is left out: jxl-oxide misdecodes
its (valid) files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:19:30 -04:00
radialmonster 0bd3f3715d LR-BEHAV-GPU: no indirect-call validation pipeline, which Intel Metal fails to compile (issue #250)
On a 2015 MacBook Pro (Intel Iris/Metal), 0.5.0 never opened its window, even with
LIGHTCRAFT_GPU=0: wgpu's indirect-call validation builds an internal compute pipeline when the
window's device is created, Metal's compiler fails on it ("Compiler encountered an internal
error"), the device is lost and eframe exits. That validation only guards indirect draws and
dispatches, which neither LightCraft nor egui issue.

backend::instance_flags clears InstanceFlags::VALIDATION_INDIRECT_CALL for every instance
LightCraft creates (the window's, Gpu::new and the denoise device), unless
WGPU_VALIDATION_INDIRECT_CALL asks for it. Tests: the flag rule, and that the window's instance
descriptor goes without it.
2026-10-10 12:11:41 -04:00
Alex ChambersandClaude Opus 5.5 6dca631482 LR-IMP-CAMERA-COVERAGE: SRF and DSLR-A100 raws open again (#702)
Since #281, probe takes a Sony file as a raw only with raw evidence, and
two layouts have none of the kinds has_raw_ifd looks for: SRF (DSC-F828,
an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data
addressed by SubIFDs). Both were "not a readable photo". For Sony, IFD0
samples of 12 or 14 bits, or model DSLR-A100 with an old-style JPEG IFD0
(compression 6) and SubIFDs, now count too; an 8- or 16-bit camera TIFF,
or an exported A100 TIFF with a SubIFDs thumbnail, stays an image. The public F828 and
A100 samples open through their previews as on main 5436951; 133 other Sony files unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:50:29 +02:00
Jason Baldwin 61df5d304f LR-MASK-BRUSH: holding the brush still adds no coverage; flow builds up by passes (#518)
A stationary hold recorded one pointer sample per frame and each sample laid
another dab at the same spot, so flow accumulated with hold time and the
feathered edge hardened. brush_dabs now lays no dab for a sample that hasn't
moved from the last one, and the canvas no longer records repeated samples at
the same position. Moving strokes and passes back over an area are unchanged.
2026-10-10 17:21:23 +02:00
Jason Baldwin 923b1cde5e LR-MASK-BRUSH: the live brush stroke places dabs like the committed one, no gaps on fast strokes (#517)
While the button was held the Detail view drew one hard tinted circle per
pointer sample, so a fast stroke showed separate dabs; the committed stroke
interpolates dabs a quarter radius apart with feather and flow. The live
stroke now uses the pipeline's dab placement (push_dabs, shared with
brush_dabs), dab falloff and flow accumulation in an incremental screen
buffer (crates/ui-egui/src/brush_live.rs): each frame stamps only the new
samples' dabs and uploads only the rectangle they touched. push_dabs also
caps the dabs of a hostile segment instead of looping without bound.
2026-10-10 17:21:22 +02:00
Jason Baldwin bbcbbebf8e LR-EDIT-COPY-PASTE: Enter applies the Copy and Paste Settings dialogs (#735)
Return presses OK in Choose Edit Settings to Copy and Paste Selected
Settings when no widget has the keyboard focus (a Tab-focused button or
checkbox takes Return itself, as egui does). Esc already cancelled them;
tests now cover both keys.
2026-10-10 16:49:39 +02:00
goujichengandDeepSeek V3.2 ab438b05ca LR-IMP-PREVIEW-READ: a preview reads the raw's header, not the raw
A grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which
sits near the start of the file — for a 24 MP Fuji RAF, 5.6 MB of a 56 MB file (measured:
DSCF9492.RAF). The loader read the file whole and threw the sensor data away, so every preview
paid for 50 MB nobody looked at. On a local disk that is merely wasteful; on a network share,
where photographers keep libraries, it is the wait: the same preview took 10 s over a GVFS/SMB
Wi-Fi share against 0.96 s after this change.

- `lightcraft_raw::bounded_preview(bytes)` locates the preview from the header alone and reports
  its byte range; `embedded_preview_from_range` extracts it, byte for byte what `embedded_preview`
  returns for the whole file (the pointer is read from the RAF header's fixed u32s at 84/88).
- `engine::files::preview_window` reads a 4 KiB prefix, then — because the container reader clamps
  a slice to the buffer it was given, so a short prefix reports a plausible but truncated 3948
  bytes — re-reads exactly the extent the header declares, and takes the window out of the prefix
  rather than reading those bytes twice. `LIGHTCRAFT_PROFILE=1` prints what each preview cost.
- Formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix,
  CRW/MRW/X3F scans), a lying pointer, a short read and a file that changes underneath us all fall
  back to the whole-file path: a damaged header never costs the user their preview.
- Tests: the header range matches `embedded_preview` byte for byte, with a clamped prefix, a padded
  extent, a late pointer, a lying pointer and a non-JPEG window covered; on a synthetic RAF with a
  200 KB tail the quick path and the whole-file path produce identical pixels and the read stays
  under half the file; `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`) measures a real
  file on a share. No raw fixture is committed.
- `docs/raw-preview-reads.md` records the mechanism and the numbers.

Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
2026-10-10 22:16:04 +08:00
radialmonster e7f8faf77d LR-IMP-FORMATS: EOS R5 Mark II crop-mode CR3s develop without a black band
The R5 Mark II's APS-C crop-mode files (5376 x 3574) state an IAD1 active area of x 132..5243,
but columns 132..~256 sit at the black level and the image runs to the right edge (measured on
raw.pixls.us 7883/7884), while their recommended crop, x 272..5359 (5088 x 3392, the camera JPEG's
size), lies on the image. The recommended crop didn't fit inside the stated active area, so the
whole active area was kept: a black band down the left and ~130 image columns lost.

When the recommended crop doesn't fit inside the stated active area, it is now the valid area.
On 7883 the render's high-pass correlation with the camera JPEG goes from -0.13 to 0.99. The
active area and crop of the other 108 decodable CC0 CR3s on raw.pixls.us are unchanged.

RENDER_CACHE_VERSION 49.
2026-10-10 10:14:32 -04:00
Jason Baldwin 76e633c9e1 LR-EDIT-COLOR-TEMP: Kelvin white balance for raws whose camera has colour matrices (#730)
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.

Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
  catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
  the same from the decoded file. A corpus test asserts they agree.
  Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
  the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
  (JPEG fit / profile / spectral fallback) is unchanged, and the slider
  adapts from that white with Bradford. The camera-space rebalance stays
  reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
  crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
  #510 as-shot shift.

Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
  white balance without a scale keeps its old meaning. On a spectral-Kelvin
  raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
  adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
  cone space (local::legacy_white). Von Kries scalings compose exactly, so
  the matrix matches to f32 and the pixels are identical
  (pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
  media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
  reset) first runs local::normalize_wb: a legacy value becomes the Custom
  Kelvin value with the same look and is marked Kelvin. Partials without a
  scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
  by older imports when the file is next decoded or reloaded (system op,
  not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.

Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
2026-10-10 16:09:31 +02:00
TicT4xandClaude Opus 5.5 b59ffcc4c9 raw: find the saturation plateau despite stray samples above it
white_from_data anchored the plateau search on the absolute maximum, so a
handful of stray samples above saturation (Canon EOS 70D CR2: 83 of 20.6M,
up to 14426 vs. a plateau at 13583) made it fall back to 2^bits-1. The
image rendered ~0.3 EV dark and clipped highlights were never seen as
clipped. Skip up to half a plateau's worth of strays when locating the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:07:47 +02:00
Jason Baldwin b8f868830b LR-CROP-STRAIGHTEN: every way of setting the crop angle keeps the crop inside the photo (#742)
develop.set / develop.adjust / develop.quickAdjust / develop.resetControl on crop.angle
(MCP set_develop, the control channel, lightcraft-cli render --set) and crop.set {angle}
now turn the crop as crop.straighten does, through the new CropGeometry::with_angle:
kept if it still fits, else the largest crop of its aspect inside the rotated photo.
Before, they left the full rect, and the export took in smeared edge pixels in the corners.
2026-10-10 16:01:25 +02:00
Luniiya c8bca35210 Merge remote-tracking branch 'upstream/main' into feat/system-appearance
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/whats-new.md
2026-10-10 16:00:16 +02:00
radialmonster d803d905b4 LR-BEHAV-LOCALIZE: craft-fonts faces of other scripts are UI fallbacks (#736)
Thai folder and file names drew boxes: the UI fell back only on craft-fonts' CJK faces, so a
Thai face (craft-fonts #15) or the Arabic one already there was never used. fonts::ui_fallback
keeps the CJK faces first, in their per-language order, then appends every face of another
non-Latin script, whatever the interface language (a Latin-only face is no fallback: Inter covers
Latin). theme.rs builds the Proportional, SemiBold and Monospace families from it.

Tests: the fallback order on synthetic faces; with a craft-fonts build that has a Thai face,
every UI family falls back on it, and the shipped Thai and Arabic faces have their script's glyphs.
2026-10-10 09:57:14 -04:00
lzjtugjc be2dac6d85 README: add a Simplified Chinese translation (zh-hans)
The interface ships in nine languages but the README only read in English, so a
Chinese reader met the project in a language it does not have to use.

README.zh-hans.md follows README.md section by section (569 lines, 27 headings,
7 tables, 8 code blocks), keeping every link, image, command, shortcut and
identifier byte-identical; only prose, alt text, code comments and table headers
are translated. Terminology follows the shipped interface catalog
(crates/ui-egui/locales/zh-hans.json) so the README and the app agree:
曝光, 白平衡, 颜色混合器, 胶片窗格, 留用, 正方形网格, 垂直校正.

Both files gain a language switcher at the top.
2026-10-10 21:54:25 +08:00
UnemarsandClaude Opus 5.5 5bcedbe6fc NEF: measured image area and white level for the Nikon D4
The D4 records no CropArea. Its 4992 x 3292 data keeps 2 columns of optical
black left of the image and 42 right of it (then 8 empty ones), which showed
as purple stripes on both sides; the camera's own 4928 x 3280 JPEG sits at
(8, 6). Green saturates at 15546-15672 while red and blue run on to 16383,
so the data's own top left clipped highlights magenta; one level of 15520
keeps them white without moving the white balance.

Measured on 148 uncompressed 14-bit NEFs from one body by aligning the
sensor data with the embedded camera JPEG.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 15:38:18 +02:00
Jason Baldwin 60844cf162 LR-EDIT-CROP: no artefact inside the crop overlay when the crop is rotated (#731)
The dimming outside the crop box was four quads, each joining a side of the
photo to the matching side of the box. Aligned, that is the photo minus the
box; rotated, the quads cross themselves and each other, so wedges inside the
box were dimmed and wedges outside doubly dark, growing with the angle (the
reporter's -45° shows it best). The export was never affected: the overlay
alone was wrong.

The dimming is now `outside_crop_mesh`: the photo's rectangle minus the box,
as one mesh of four disjoint half-strips (outside edge i, inside edge i+1,
clipped to the photo). A flat or non-finite box dims nothing.

Tests: a coverage test over several box sizes and angles (every photo point
outside the box in exactly one triangle, none inside the box or outside the
photo), a degenerate-box test, and a headless pixel test at -45° (inside the
box the frame matches the 0° frame; the photo's corner is dimmed), which
failed on the old mesh.
2026-10-10 15:12:03 +02:00
Dylan M. Taylor 0ed5fcd938 Linux: default to XWayland for file drag-and-drop on Wayland desktops 2026-10-10 09:02:26 -04:00
radialmonster 943448e7c9 LR-EDIT-OPTICS-PROFILE: apply embedded Fujifilm RAF distortion and vignetting tables
Fujifilm RAFs carry per-shot GeometricDistortionParams (0xf00b) and VignettingParams (0xf010)
in the raw IFD. Derived by measurement against the cameras' own JPEGs and LightCraft's
uncorrected renders: [scale, n knots, n values], scale x n = half diagonal of the recorded image,
last knot = farthest corner, distortion in percent indexed by the source radius with the
frame-filling zoom, vignetting as 100/V gain. They become a WarpRectilinear and a
FixVignetteRadial for 59 validated bodies (same path as the Sony ARW and Panasonic RW2
corrections); unmatched bodies and tables with another scale stay uncorrected.

On 67 body/lens combinations the corrected render's radial shape error against the camera JPEG
falls from a median of 0.72 % to 0.05 % of the half diagonal. The lateral CA table (0xf00f) is
real but is not applied: our demosaic already registers the colour planes and the stored shift
over-corrects. See docs/fuji-lens-corrections.md.

RENDER_CACHE_VERSION 48.
2026-10-10 08:38:41 -04:00
Jason Baldwin 1b178ca2e5 LR-EDIT-DENOISE: a running job's picture is Ready only when the job reports (#719)
The denoise worker saves the picture, then tears down its rayon pool and
returns before it writes its outcome. A `denoise.pump` in that window saw the
file on disk (`denoise_check_photo` for the selected photo, or a reindex),
marked the photo Ready and still held the job as running: `PhotoState::Ready`
and `denoise_busy()` at once, the activity row up for a photo that said it was
done, and the flaky `a_photo_waits_for_its_turn…` assertion on a loaded
machine.

The disk checks now leave the running photo to its job: `denoise_poll` makes
it Ready in the same pump that clears `running`. A test blocks the worker in
the model loader, writes the picture in its place and shows the photo stays
Running until the worker reports, then Ready and idle together.
2026-10-10 14:05:37 +02:00
Twdhsi-666 e40bf5792a Rebase onto current main so the 15 upstream progress keys are kept 2026-10-10 20:00:00 +08:00
Jason Baldwin 90875cf8f2 LR-IMP-FORMATS: probe compressed NEFs from their headers, not the Huffman stream (#708)
`probe_info` (every file an import adds) ran the whole Nikon Huffman
decode on a compressed NEF because `nef::decode` had no header mode and
the masked-trailing-column test wanted the samples: 386 ms per 24 MP
file, the full decode time, where every other format probes in < 1 ms.

The header has no source for those columns (the D5100 and D7000 write 8
masked columns at value 0, compressed and uncompressed alike, with no
CropArea or other maker-note tag), so the probe cannot skip the test.
Instead both modes now judge the active width from the first 128 rows
(`nef::MASK_ROWS`) against the nominal white level: the header probe
decodes only those rows (`nefc::decode_rows`, the stream is sequential;
uncompressed files read only the strips holding them) and the full
decode uses the same rule, so `probe_info == RawImage::info` still
holds. On every corpus NEF/NRW the answer is the same for 8 to 512 rows
as for the whole frame. The whole-image size and truncation checks still
apply in both modes; the "lossy after split" end-of-strip rule is only
checked when the whole strip is decoded.

Release, same files: probe 66 -> 3.4 ms (D5100 lossless), 72 -> 3.2 ms
(D7000 lossy 12), 88 -> 3.1 ms (D7500 lossless 12), 92 -> 3.7 ms (Z f
lossless), 23 -> 1.5 ms (D5100 uncompressed), 54 -> 2.7 ms (B700 NRW).

Tests: a synthetic lossless NEF whose stream is cut off after two
thirds of the rows probes to the same info as the whole file while the
full decode reports the truncation; synthetic uncompressed NEFs with
masked columns in the top rows, lower rows, all or none give the same
active area in both modes. Both failed before the fix. Docs: the
`probe_info` doc comment, docs/parity.md (LR-IMP-FORMATS),
docs/gpu-pipeline.md.
2026-10-10 13:58:20 +02:00
MarekSpodBiedry d35d0a3dd2 Web: a failed render worker start no longer shifts the other workers' slots (#229) 2026-10-10 13:58:04 +02:00
Twdhsi-666 3c5e2f3cb2 locales/zh-hans: translate the remaining AI Denoise strings 2026-10-10 19:51:57 +08:00
Jason Baldwin 3285e0282d LR-LIB-DELETE: importing a deleted photo's file again brings it back (#706)
Import a photo, delete it, import the same file again: the import skipped
it as a duplicate "already in the library" while the grid showed it in
Recently Deleted, and the user was stuck (issues #298, #706). The default
for a file whose photo is in Recently Deleted is now to restore the photo,
with its edits, rating and albums (`onDeleted: restore`; `skip` keeps the
old behaviour, `fresh` imports a clean copy). A photo matched by content
whose file is gone is relinked to the imported file. The import review
checks such files, badges them "Will be restored", counts them as "in
Recently Deleted" and starts its choice on Restore them; dropped files
say "Restored 1 photo from Recently Deleted".

Browsing a folder in Local, Auto Import and a Lightroom catalog import
keep skipping: only an import the user asked for brings a photo back.
2026-10-10 13:51:10 +02:00
Twdhsi-666 aa31ecdc56 locales: complete Simplified Chinese (zh-hans) catalog 2026-10-10 19:50:44 +08:00
Jason Baldwin 0de789bac9 LR-LIB-LOCK: parity row notes the explicit release (#680) 2026-10-10 13:48:13 +02:00
Jason Baldwin 58f7e74f2a LR-IMP-FORMATS: file extensions match whatever their case (#705)
Issue #705 (Linux, Nikon `.JPG` hidden by the Add Photos dialog) is the
symptom of #342, fixed by e2d35de8 after the 0.4.0 release and shipped in
0.5.0: 0.4.0 handed GTK / the desktop portal `*.jpg`-style globs, which
they match case-sensitively. Every dialog filter now goes through
`add_filter_nocase` (`*.jpg`, `*.JPG`, `*.[jJ][pP][gG]`).

An audit of every other extension match (folder scan, Browse Folder,
drag-and-drop, the CLI, `codecs` sniffing, the XMP sidecar lookup,
raw+JPEG stems, export format by extension) found them all folding case
already. This adds the regression test that proves it: a folder with
`a.JPG`, `b.Jpg`, `c.JPEG`, `d.PNG`, `e.NEF`, an `a.XMP` sidecar and a
`b.Jpg.xmp` sidecar imports all five (both sidecars read) and
`library.browse` shows all five. Docs note what was case-sensitive and
when it was fixed.
2026-10-10 13:33:32 +02:00
Fabio LussiandClaude Opus 5.5 584a26085c Hold Shift while dragging a crop handle to keep proportions
crop.drag gains an optional keepRatio param that constrains the drag to
the frame's current aspect even when the lock is off. The crop panel
sends it while Shift is held.

Closes #614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:28:58 +02:00
jazzautomationsandClaude Fable 5.1 9c2bad1146 Linux/FreeBSD: file dialogs are children of the app window
Every rfd dialog is built from one template that has the main window as
its parent (set once in `services` from the `CreationContext`). Without
a parent the xdg-desktop-portal picker on Wayland is a stray top-level
("Failed to associate portal window with parent window"): tiling
compositors put it beside the app, and a fullscreen window hides it
entirely, so Import Photos… looks like a hang. rfd exports the Wayland
surface through xdg_foreign; `FileDialog` is `Clone` and `Send`, so the
template also serves the picker on its own thread (#191). Not set on
macOS (sheet) or Windows (modal), which this does not change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 08:09:28 -03:00
Jason Baldwin 47e9181f69 LR-PREF-PERFORMANCE: GPU fallback reason per render; the export fallback test no longer reads process-wide state (#675)
`export_falls_back_to_the_cpu_when_gpu_work_is_lost` asserted on
`lightcraft_gpu::last_fallback()`, one value for the whole process that
every other engine test's GPU render overwrites between the export and
the assertion (out of device memory under parallel load, once "a GPU
render panicked"). It also switched the GPU off for the whole process
through the injected fault and cleared everyone's state with
`reset_failures()`.

A render now reports its own fallback: `lightcraft_gpu::try_render`
returns `Result<Rendered, Fallback>` (`NotTried` when the GPU was not
used, `Failed(reason)` when it was tried and the CPU must render), and
`render` is a thin `.ok()` over it. `media::develop_reporting` passes
the reason on as `RenderResult::gpu_fallback`, an export carries it as
`Exported::gpu_fallback`, and each file in an `app.export` batch result
has `gpuFallback` when its render fell back. `last_fallback()` stays as
the process-wide diagnostic for `ui.inspect` → `perf.gpuFallback`.

A simulated failure (`inject_fault`) is thread-local, and so are its
consequences now: a fatal injected fault stops GPU use on the injecting
thread only (`available()` false, `unavailable_reason()` set there);
`reset_failures()` clears that and nothing else, so neither reaches the
other tests in the process. Real failures still stop the whole process.

The engine test asserts on its own export's reason and checks that
another thread still has the GPU; `crates/gpu/tests/fallback.rs` uses
`try_render` and checks the same; a new batch test checks `gpuFallback`
lands on the file whose render fell back. Docs: gpu-pipeline.md,
control-protocol.md (app.export result), parity.md.
2026-10-10 12:45:42 +02:00
Jason Baldwin 7883333420 LR-IMP-CAMERA-COVERAGE: Sony ILCE-7CM2 ARW codings in the corpus (#697)
Issue #697 (ILCE-7CM2 lossless compressed M-size ARW "washed out" in 0.4
and 0.5) is the 512-vs-1024 YCbCr pedestal of #535 item 3, fixed on main
by 3100cc1a after v0.5.0. Measured on the reporter's file: 0.5.0 renders
the darkest half of the dusk scene +11 L* and blue against the camera
JPEG, with the lamplight turned white (mean dE 11.8); main is within
+3 L* (dE 8.9). Its darkest 0.1 % per channel sits at 1017/1020/1024,
confirming the 1024 pedestal on a fifth body.

- Corpus: two CC0 raw.pixls.us ILCE-7CM2 files, lossless L and the
  compressed "M-size" coding, which is Bayer at 4736x3132 with black 512
  (only the lossless M/S codings are YCbCr), so a size-based pedestal
  rule would break it.
- Tests: corpus_sony_a7cm2_codings (model, layout, levels, crop, as-shot
  gains, header/full agreement) and both files in the #535 data-floor
  test.
- docs/parity.md: the ILCE-7CM2 verification on the Sony coverage row.
2026-10-10 12:42:51 +02:00
Jason Baldwin 8166185a0f LR-LIB-LOCK: release the library lock explicitly on drop (#680)
LibraryLock freed the library by letting its File close. On Unix the
flock lock belongs to the open file description and lasts while any
descriptor on it is open. When another thread spawns a process,
posix_spawn copies the whole descriptor table into the child and
O_CLOEXEC only closes the copies at execve, so a LibraryLock dropped in
that window stayed locked until the child exec'd. Re-acquiring the
library then failed with LockError::InUse(None) ("already open in
another LightCraft program") although nobody had it open. That is the
flake in tests_lock::second_opener_is_refused_until_the_first_lets_go,
whose neighbour lock_held_by_another_process_is_released_when_it_dies
spawns the test binary on a parallel libtest thread.

Drop now removes the owner note while still holding the lock (so it
can't delete the next holder's), then calls File::unlock(): LOCK_UN
through our descriptor frees the lock whatever copies a child still
holds. An unlock error is logged, never a panic; the handle still closes
right after, which releases the lock once every copy is gone, as before.

Tests: the_library_is_free_once_dropped_even_if_a_child_holds_a_copy
holds a try_clone of the lock's descriptor across the drop (the same
open file description a child gets between fork and exec) and
re-acquires; dropping_the_lock_frees_the_library_while_another_thread_
spawns_processes runs a thread spawning /bin/true against two seconds of
acquire/drop cycles. Both fail every run without the fix (the first
deterministically) and pass with it; the flaky pair went from 3/100 to
0/100 failed runs here. Both are cfg(unix): Windows handles aren't
inherited by CreateProcess.
2026-10-10 12:36:16 +02:00
aayush113002andClaude Fable 5.1 ec1e26545e M8: spot.findBlemishes — heal small blemishes on detected faces
A sibling of spot.findDust for portraits: with the YuNet detector installed, small
round spots on each face's skin that are darker or redder than the surrounding skin
become heal spots (one undo step). The detector's box and landmarks define the skin
(ellipse, skin-colour gate, eye band / brows / nostrils / mouth cut out); a candidate
must stand out from a blurred copy beyond the sensitivity threshold and 2.5x the local
texture, then grow into a small roundish blob. Conservative: faces under 160 px get
nothing, faces with more than 12 candidates (stubble, freckles, pores) get nothing, at
most 6 per face (maxPerFace). Classical, no model beyond the detector.

Tests (synthetic, no model): cheek spots found at their positions, clean face empty,
spots on features ignored, small face ignored, speckled face treated as texture,
hostile boxes and an empty image do not panic. New LR-REM-BLEMISH parity row, Find
Blemishes section in docs/faces.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:48:31 +05:30
aayush113002andClaude Fable 5.1 997bd7a4ed M8: read Lightroom spot removal (crs:RetouchAreas, RetouchInfo) from sidecars and presets
Heal and clone spots stored as crs:RetouchAreas (Mask/Ellipse, Mask/CircularGradient
or a brushed Mask/Paint target; SourceX/OffsetY as the source's absolute position,
honoured only when the source was set explicitly; Opacity and Feather 0..1) and the
legacy crs:RetouchInfo list become our spots when a sidecar, embedded XMP, XMP preset
or .lrtemplate is read. Width fractions are converted to long-edge fractions with the
target's aspect, like radial masks. Unsupported spot masks are reported as
`Spot: <kind>`. Format established by observing what a sidecar written for a healed
photo contains; clean-room.

Tests: heal with explicit source, clone with automatic source, brushed removal,
circular-gradient spot, unsupported mask reported, portrait-target scaling, legacy list
on its own, and a render test. docs/xmp-interop.md gains a Spot removal section;
LR-META-XMP row updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:47:59 +05:30
radialmonster ea7777107d LR-IMP-FORMATS: decode Kodak DCS520C raw TIFF (two-component lossless JPEG with response curve) 2026-10-10 06:06:18 -04:00
Jason Baldwin c739f12e45 Merge origin/main into #690 (both sides added locale catalog entries: kept both) 2026-10-10 11:53:09 +02:00
Jason Baldwin 8a60e3cd30 LR-PREF-PEOPLE: Recognise faces stays off until a recognition model is chosen (#678)
Settings ▸ Faces let the switch be turned on with no recognition model
chosen or installed: the box ticked, Settings closed without a word, and
recognition was on with nothing to run. Now the box is enabled only with a
chosen, installed recogniser (and a build that can run models), says
"Choose a model below (Use) to start." on hover, and that hint stays under
it on or off; a box left on by an older build can still be switched off.
faces.enable {enabled: true} refuses without such a model, so the control
channel and MCP cannot reach that state either, and removing the last
recogniser switches recognition off instead of leaving it on with nothing
chosen. Installing a model still chooses it and switches recognition on.

Tests: tests_face_models::recognition_cannot_be_turned_on_without_a_chosen_model
and tests_panels::recognise_faces_stays_off_until_a_model_is_chosen (fails
on main at the first click). Docs: faces.md, parity LR-PREF-PEOPLE.
2026-10-10 11:51:56 +02:00
Jason Baldwin 94fed226df MENU-BAR: a scrolled menu draws no scrollbar over its rows (#691)
A menu taller than the room under the menu bar scrolls inside an egui
ScrollArea whose floating scrollbar is drawn over the rows' right edge,
where the shortcut text ends. At 1.0x the 2-point handle grazes the last
glyph; at 1.75x the bar is in its wide hovered state with a semi-opaque
background and hides the last character of every shortcut, which reads as
clipping. The scroll content, its clip and the rows are the level's width
in both modes (instrumented), so nothing else needs to move: the scroll
area hides its bar. The up/down strips, the wheel and dragging were already
the designed ways to scroll a menu, like native menus; the native macOS
menu bar does not go through this code.

Test: tests_menubar::a_scrolled_menu_draws_no_scrollbar_over_its_shortcuts
(a 700 pt and a 1400 pt window: no tall narrow rectangle along the level's
right edge, equal widths), via a new HeadlessView::painted_shapes; fails on
main with the bar's track [512, 59]-[514, 660] and handle.
2026-10-10 11:39:28 +02:00
Luniiya 28a773d460 Merge remote-tracking branch 'upstream/main' into feat/system-appearance
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/state.rs
#	crates/ui-egui/src/theme.rs
#	docs/control-protocol.md
#	docs/parity.md
#	docs/whats-new.md
2026-10-10 11:31:38 +02:00
Elliot GrahamandCodex 9682caae2a LR-EDIT-OPTICS-PROFILE: guard Sony aspect crops and validate the full corpus
Keep image framing inside the validated 3:2 scope even when DefaultCrop retains native geometry. Cover all 23 Sony fixtures, preserve crop precedence, invalidate old renders, and record fresh Sony reference measurements.

Co-authored-by: Codex <noreply@openai.com>
2026-10-10 10:30:44 +01:00
MarekSpodBiedry ea224d899e Share the SAM 3 model folder default between desktop and headless sessions (#619) 2026-10-10 11:27:50 +02:00
MarekSpodBiedry 4cce2d169e UI: hovered star gets the icon-button hover square (#647) 2026-10-10 11:26:36 +02:00
radialmonster ba65d0c8d1 LR-PREF-PERFORMANCE: cover every GPU kernel build with the crash marker, catch internal compile errors (issue #250) 2026-10-10 05:23:48 -04:00
Brandon Thomas b70ae1741e Merge pull request #692 from storytold/claude/integrate-batch-25
Integrate #573, #498, #602, #601, #560, #555, #480, #590
2026-10-10 05:14:58 -04:00
Jason Baldwin bec920f67d LR-LIB-ALBUM: albums hold library photos; Local photos are skipped, a Local-only selection is refused (#684)
Album views list library photos only, but album.create {addSelected},
album.addPhotos and album.toggleTarget (B) took the selection as is, so a
browsed Local photo became an album member that never shows: count 1, an
empty album, a cover that can't render.

Engine: the three commands drop Local photos from their targets and report
`skipped`; a selection of nothing but Local photos is an error that says to
Add to My Photos first; album.addPhotos and album.toggleTarget use a new
has_library_selection enabler. album.create still makes the album, minus
the Local photos.

UI: for a Local-only selection the grid's right-click Add to Album is off
with a hover hint, Photo ▸ Add to Album starts with a disabled "Add to My
Photos First" item and its entries and New Album… are off, a folder row's
Add Selected Photos is off, and a drag over an album row shows not-allowed
and toasts the hint on release. A mixed selection runs and toasts how many
Local photos were skipped (drop, context menu, folder row, New Album
dialog). Context-menu album entries are button:contextAlbum:<id>.

Messages added to every locale catalog. Tests: tests_albums_local.rs
(engine) and tests_panels::local_photos_cannot_be_added_to_albums_until_
they_join_my_photos (UI, fails on main). Docs: parity rows LR-LIB-ALBUM,
LR-IMP-LOCAL, MENU-PHOTO-ADDTOALBUM.
2026-10-10 10:54:07 +02:00
radialmonster ed705a4483 LR-IMP-CAMERA-COVERAGE: Sony DSC-R1 reads its SR2SubIFD (black, white balance, crop): seed derived from the key 2026-10-10 04:53:14 -04:00
Ikki NishiharaandClaude Sonnet 5.5 87cd873d78 XMP merge: keep refusing repeated attributes, in linear time
quick-xml's own duplicate-attribute check is quadratic. Switch it off and
refuse a repeated name with a per-element set instead, so scan keeps its
behaviour on malformed packets without the quadratic cost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:35:20 +09:00
Ikki NishiharaandClaude Sonnet 5.5 a4436597ad DNG: cap the pixels the bad-pixel opcodes repair
FixBadPixelsConstant listed every pixel equal to its constant, and the
FixBadPixelsList cap was checked only after a whole rectangle was pushed.
Cap both at the number the list arm already used (1 << 22).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:29:44 +09:00
Jason Baldwin 4721627d31 LR-LIB-ALBUM: right-click ▸ Add to Album offers New Album… (#676)
The grid context menu's Add to Album submenu listed only the existing plain
albums, so in a library without one it came up empty. The Photo menu's
submenu already ends with New Album…, and album.create adds the selection
by default; the context menu now has the same entry (after a separator
when albums exist). The submenu and the entry are registered as
button:contextAddToAlbum / button:contextNewAlbum for the control channel.

Test: right_click_add_to_album_offers_a_new_album_when_there_is_none
deletes every plain album, right-clicks a thumbnail, hovers Add to Album,
clicks New Album…, names and confirms it; the album exists and holds the
photo. Fails on main with "no widget button:contextAddToAlbum".
2026-10-10 10:29:08 +02:00
Luniiya 216efab838 UI: Add system appearance and light themes 2026-10-10 10:18:31 +02:00
Brandon ThomasandClaude Opus 5.5 0b1d80f08d LR-MASK-OVERLAY: test the mask overlay as a zoomed window
There was no zoom/pan reset on main to fix: what this PR changes is that a
zoomed view with the mask overlay shown is now a canvas-sized frame plus a
sharp window carrying the overlay, as without it, instead of a whole-frame
render at the zoomed size. Tests: the overlay on a window matches the same
pixels of the whole render for radial, refined linear and sky masks in
three views (pipeline tests_window.rs), and at 1:1 the loupe uses a window
with the overlay, keeps zoom and pan, and swaps tiles when the overlay is
hidden or shown (ui-egui tests_preview_limit.rs). Parity row updated. No
render output change (no cache bump).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:41 -04:00
Alex ChambersandClaude Opus 5.5 04f8146d10 Issue #99: lock tests: keep the child-process test from overlapping the others
tests_lock::second_opener_is_refused_until_the_first_lets_go failed now and
then on macOS with InUse(None): the child process that
lock_held_by_another_process_is_released_when_it_dies spawns briefly holds
copies of the test process's file descriptors, lock files included, until its
exec closes them, so a lock dropped in that moment stays held. The three tests
that hold a lock in the test process now take one mutex. Under parallel load:
6 of 400 runs failed before, 0 of 400 after. Test-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:59:22 +02:00
Daniel Spruce 2aa21c2b12 LR-CROP-ASPECT: preserve concurrent crop preference recovery fix 2026-10-10 18:55:01 +11:00
Daniel Spruce d2ec802902 LR-CROP-ASPECT: resolve conflicts with upstream main 2026-10-10 18:53:11 +11:00
Brandon ThomasandClaude Opus 5.5 02a9f3a5fb LR-CROP-ASPECT: forget a remembered ratio the crop command refuses
A bad remembered value (a hand-edited settings file) made begin_crop fail,
which cancelled every crop drag on an untouched photo for good. Now the value
is dropped with a status message and the drag goes ahead. Regression test in
tests_masking.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:52:53 -04:00
Brandon Thomas c606bb4b0e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity.md
2026-10-10 03:52:44 -04:00
Mark ParienteandClaude Opus 5.5 f9bbeb9d00 LRC-LIB-CATALOG-IMPORT: find moved root folders relative to the catalog
A catalog copied from another computer (e.g. Windows to Linux) records
root folders at paths that don't exist here, so every original was
imported as missing. When a root's absolutePath isn't a folder, use the
relativePathFromCatalog Lightroom stores for roots on the catalog's
volume, resolved against the catalog's folder, and report the remapping
as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:35:25 -07:00
Daniel Spruce fd5d05e256 LR-CROP-ASPECT: defer remembered ratio until a crop gesture starts 2026-10-10 18:14:51 +11:00
MarekSpodBiedry 51d98f9256 Headless sessions honour LIGHTCRAFT_SAM3_DIR for the SAM 3 model folder (#619) 2026-10-10 09:14:36 +02:00
Brandon ThomasandClaude Opus 5.5 760819eab3 LR-MASK-SKY: drop the sky heuristic rewrite
Rendered the old and new heuristic over the 24 procedural demo scenes: the
rewrite sharpens edges on blue skies (beach, dunes, canyon), but drops warm
sunset and dark night skies to nothing, punches a hole around the sun, and
selects lavender rows, a flower and lit mountains as sky. It also changes the
render of every existing Sky mask without a process version. Back to main's
heuristic; the masking-view part of this PR stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:10:35 -04:00
Brandon Thomas 5240bdaa6f Merge remote-tracking branch 'origin/main' into HEAD 2026-10-10 03:02:17 -04:00
MarekSpodBiedry 01fc7a5e75 UI: star rating previews the hovered rating, Info tab and bottom bar (#647) 2026-10-10 09:01:03 +02:00
Daniel Spruce 06550937e8 LR-CROP-ASPECT: remember the last crop aspect as the default 2026-10-10 10:33:44 +11:00
David Campos 128539a939 masks: improve sky mask edges and horizon handling
Refactor sky masking logic to use rasterize_sky function for improved clarity and maintainability.
2026-10-09 20:04:11 +04:00
David Campos 330f824c31 masking: keep zoom and pan across mask interactions (detail)
Added a function to check if an overlay supports window rendering. Updated overlay handling in various contexts to ensure compatibility with new overlay support logic.
2026-10-09 20:03:42 +04:00
David Campos 57f54868de Update documentation for render state and windows 2026-10-09 20:03:09 +04:00
Adrian D'souza 7d3cd8afe5 XMP: preserve Upright warning modes across import paths 2026-10-09 08:54:49 +05:30
Adrian D'souza ec77f60b1b XMP: surface structured import warnings in UI 2026-10-09 08:49:38 +05:30
Adrian D'souza 20b6eb885e XMP: report unsupported photo import settings 2026-10-09 08:45:25 +05:30
313 changed files with 42431 additions and 2821 deletions

No files matched your search

+18 -2
View File
@@ -1,5 +1,7 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
# searches (a few minutes).
name: Packaging lint
on:
@@ -71,3 +73,17 @@ jobs:
EOF
- name: FreeBSD tarball layout (stub binaries)
run: bash packaging/freebsd/package.sh --dry-run
windows-install-folder:
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Install-folder fallback and precedence (real MSI AppSearch)
shell: pwsh
run: |
./packaging/windows/test-install-folder.ps1 -Arch x64
./packaging/windows/test-install-folder.ps1 -Arch x86
+77 -3
View File
@@ -5,8 +5,9 @@
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
# drafts a release.
name: Release
on:
@@ -272,6 +273,79 @@ jobs:
path: dist/release/*.flatpak
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the tarball
run: |
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
@@ -368,7 +442,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
+12 -6
View File
@@ -5,12 +5,18 @@ 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. **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.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). 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 (raw detail:
[`docs/raw-parity.md`](docs/raw-parity.md)). 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/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `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).
+13 -7
View File
@@ -5,12 +5,18 @@ 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. **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.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). 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 (raw detail:
[`docs/raw-parity.md`](docs/raw-parity.md)). 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/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `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).
@@ -80,7 +86,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
Generated
+174 -28
View File
@@ -988,6 +988,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -1062,6 +1072,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1202,6 +1221,19 @@ dependencies = [
"thousands",
]
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1447,6 +1479,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -2230,6 +2283,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2588,6 +2650,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2612,6 +2693,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jixel"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3"
[[package]]
name = "jni"
version = "0.22.4"
@@ -2909,6 +2996,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -2976,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
@@ -2987,19 +3084,26 @@ dependencies = [
"lightcraft-ui-egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"rfd",
"serde_json",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "lightcraft-catalog"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"crc32fast",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-raw",
"log",
"proptest",
"serde",
@@ -3010,10 +3114,11 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"dhat",
"lightcraft-codecs",
"lightcraft-denoise",
"lightcraft-engine",
"lightcraft-mcp",
"lightcraft-merge",
@@ -3025,12 +3130,13 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"avif-serialize",
"bytemuck",
"image",
"image-webp",
"jixel",
"jpeg-decoder",
"jpeg-encoder",
"jxl-oxide",
@@ -3053,7 +3159,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3062,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3079,7 +3185,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3094,7 +3200,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3106,10 +3212,11 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"ab_glyph",
"harfrust",
"jxl-oxide",
"libc",
"lightcraft-catalog",
"lightcraft-codecs",
@@ -3119,9 +3226,11 @@ dependencies = [
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-fetch",
"lightcraft-geocode",
"lightcraft-geom",
"lightcraft-gpu",
"lightcraft-heif",
"lightcraft-lensdb",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
@@ -3143,7 +3252,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3157,7 +3266,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"log",
"rustls",
@@ -3166,9 +3275,17 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "lightcraft-geocode"
version = "0.7.1"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
]
[[package]]
name = "lightcraft-geom"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"proptest",
"serde",
@@ -3176,7 +3293,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3194,15 +3311,31 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"heic-rs",
"rayon",
]
[[package]]
name = "lightcraft-inpaint"
version = "0.7.1"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.7.1"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
"quick-xml 0.38.4",
]
[[package]]
name = "lightcraft-mcp"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3213,7 +3346,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3229,7 +3362,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3241,12 +3374,13 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"image",
"lightcraft-color",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-inpaint",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
@@ -3258,7 +3392,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3270,7 +3404,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"libc",
"lightcraft-color",
@@ -3282,7 +3416,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3304,7 +3438,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"image",
"lightcraft-color",
@@ -3315,7 +3449,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"candle-core",
"candle-nn",
@@ -3328,11 +3462,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.5.0"
version = "0.7.1"
[[package]]
name = "lightcraft-tiff"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"proptest",
"serde",
@@ -3341,9 +3475,11 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
@@ -3366,7 +3502,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"crc32fast",
"eframe",
@@ -4169,6 +4305,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -6826,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"brotli",
"flate2",
+13 -1
View File
@@ -3,13 +3,14 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
@@ -23,6 +24,8 @@ lightcraft-raw = { path = "crates/raw" }
lightcraft-codecs = { path = "crates/codecs" }
lightcraft-meta = { path = "crates/meta" }
lightcraft-develop = { path = "crates/develop" }
lightcraft-lensdb = { path = "crates/lensdb" }
lightcraft-inpaint = { path = "crates/inpaint" }
lightcraft-scenes = { path = "crates/scenes" }
lightcraft-pipeline = { path = "crates/pipeline" }
lightcraft-gpu = { path = "crates/gpu" }
@@ -35,6 +38,7 @@ lightcraft-mcp = { path = "crates/mcp" }
lightcraft-fetch = { path = "crates/fetch" }
lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
lightcraft-geocode = { path = "crates/geocode" }
# foundation
thiserror = "2"
@@ -66,6 +70,7 @@ tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
@@ -87,6 +92,13 @@ eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same)
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] }
# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸
# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it.
zbus = "=5.19.0"
web-time = "1"
# web (apps/lightcraft-web)
+13
View File
@@ -22,6 +22,12 @@ Foundation project licensed under the Apache License 2.0, and from the CIE 1931
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
crates/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane
fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and
poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache
License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each
ported file says so.
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
@@ -48,3 +54,10 @@ also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/mai
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
licence as OFL-<family>.txt.
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
are never distributed with it. On the user's request (Photo > Download Place Names) the app
downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames
(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed
under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/);
attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own.
+28 -11
View File
@@ -12,6 +12,11 @@
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
@@ -297,18 +302,21 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
[docs/target-app-parity.md](docs/target-app-parity.md)):
- **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.
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
| Area | Status |
|---|---|
@@ -318,10 +326,10 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| 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 | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), 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 / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
@@ -332,7 +340,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), 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), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
| 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>
@@ -341,6 +349,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
## Quick start
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
@@ -368,7 +378,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
@@ -481,6 +491,11 @@ option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-window
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `lightcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39 or newer, e.g. Ubuntu 24.04+).
On a Wayland desktop LightCraft runs through XWayland when it is available, because only X11 gives
windows file drag-and-drop; set `LIGHTCRAFT_WAYLAND=1` to use native Wayland (sharper fractional scaling, no drop).
### FreeBSD
| Build | File |
@@ -567,3 +582,5 @@ Forks and modified versions must remove them.
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+567
View File
@@ -0,0 +1,567 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
</p>
<p align="center">
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">全部 Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的编辑视图:放大视图中是安塞尔·亚当斯的《特顿山脉与蛇河》,右侧打开着 Light 与 Effects 面板,底部的胶片窗格中是四张展示照片" width="100%">
<br>
<sub><i>安塞尔·亚当斯,《特顿山脉与蛇河》(1942)。公有领域,美国国家档案馆。在 LightCraft 中显影。</i></sub>
</p>
> [!NOTE]
> **ArtCraft 是一个由形形色色的创作者组成的社区。** 数字艺术、生成艺术、音乐、游戏 &mdash;
> 只要你在创作,你就是我们中的一员。**[来 Discord 打个招呼吧](https://discord.gg/artcraft)。**
<p align="center">
<a href="#edit-like-you-mean-it">编辑</a> ·
<a href="#color-grading-the-cinematic-way">颜色分级</a> ·
<a href="#before--after">修改前后对比</a> ·
<a href="#masking-that-goes-where-you-point">蒙版</a> ·
<a href="#presets-profiles--the-color-mixer">预设</a> ·
<a href="#organize-everything">图库</a> ·
<a href="#built-for-agents">智能体与 MCP</a> ·
<a href="#fast-native-private">性能</a> ·
<a href="#feature-status">功能状态</a> ·
<a href="#quick-start">快速开始</a> ·
<a href="ROADMAP.md">路线图</a> ·
<a href="#downloads">下载</a> ·
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
<br>
## Edit like you mean it
LightCraft 是一个装在单个原生应用里的完整暗房。每一项调整都是**非破坏性**的,原始文件永远不会被改动。
每个滑块都经过一条**场景参考、宽色域、32 位浮点的管线**渲染:高光像胶片一样柔和滚降,阴影被提亮却不产生光晕,
色彩从拍摄到导出始终保持干净。
<table>
<tr>
<td width="50%" valign="top">
### ☀️ Light
**曝光(Exposure)、对比度、高光、阴影、白色色阶、黑色色阶**,配合边缘感知的局部色调映射(对对数亮度做引导滤波)。
把高光拉到 −100,就能救回一片过曝的天空,而且不会出现用简单曲线时会有的灰色光晕。
### 🎨 Color
按**色温**和**色调**调整**白平衡**(raw 用开尔文,JPEG 用相对值),带预设、自动,以及点击即可中和的**吸管**。
保护肤色的**鲜艳度**、**饱和度**、8 个色段的**颜色混合器**(色相/饱和度/亮度),以及带混合与平衡的
三路**颜色分级**色轮。所有计算都在 OkLCh 这个现代感知色彩空间里完成。
</td>
<td width="50%" valign="top">
### ✨ Effects
让细节更细腻的**纹理(Texture)**、让中间调更有力的**清晰度**、**去朦胧**(暗通道先验加引导滤波细化;
推到负值可以增加空气感)、裁剪后带高光优先、圆度和羽化的**暗角**,以及分辨率无关、可调大小与粗糙度的
胶片**颗粒**。
### 📈 Tone Curve
带可移动分割点的参数化区域曲线,**外加** RGB、红、绿、蓝的点曲线。曲线在构造上就是单调的,
所以不会意外出现影调反转。
</td>
</tr>
</table>
<p align="center">
<img src="docs/images/curve-tetons.jpg" alt="Light 面板下方打开的色调曲线,RGB 点曲线上是一条平缓的 S 形曲线,作用于《特顿山脉与蛇河》" width="100%">
<br>
<sub>RGB 点曲线上一条平缓的 S 形曲线,就在 Light 滑块正下方。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub>
</p>
<br>
## Color grading, the cinematic way
用随处拖动的色轮,分别给阴影、中间调和高光做分离色调。下图中,多萝西娅·兰格的
*《移民母亲》* 只拖动两次就获得了温暖的、类似照片冲印的色调(高光 42°,阴影 28°)。
<p align="center">
<img src="docs/images/grading-migrant-mother.jpg" alt="中间调、阴影和高光的颜色分级色轮,旁边是多萝西娅·兰格的《移民母亲》,被调成温暖的冲印色调" width="100%">
<br>
<sub>Color 面板中的阴影、中间调和高光色轮。<i>多萝西娅·兰格,《移民母亲》(1936)。公有领域,美国国会图书馆。</i></sub>
</p>
<br>
## Before & after
按住 <kbd>\\</kbd> 偷看原图,按 <kbd>Y</kbd> 并排对比,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分屏查看。
下面每一张都是 LightCraft 的真实截图,由智能体通过[控制通道](#built-for-agents)自动截取。
<table>
<tr>
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《特顿山脉与蛇河》的并排修改前后对比:修改后的云层更深,河岸边的阴影更开阔"><br><sub><b>《特顿山脉与蛇河》。</b> 高光 −45、阴影 +38、清晰度 +28、去朦胧 +18。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母亲》的并排修改前后对比:修改后更温暖,阴影被提亮"><br><sub><b>《移民母亲》。</b> 阴影 +42、纹理 +18、分离色调、暗角。<i>多萝西娅·兰格,1936(公有领域)。</i></sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的并排修改前后对比:月球地平线上方是地球,修改后略暖、更浓郁"><br><sub><b>《地出》。</b> 去朦胧 +22、高光 −30、更暖的白平衡、鲜艳度 +22。<i>NASA/比尔·安德斯,阿波罗 8 号,1968(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《蓝色弹珠》的并排修改前后对比:修改后黑色更深,云层细节更结实"><br><sub><b>《蓝色弹珠》。</b> 高光 −38、黑色色阶 −20、去朦胧 +15、鲜艳度 +30。<i>NASA,阿波罗 17 号,1972(公有领域)。</i></sub></td>
</tr>
</table>
<br>
## Masking that goes where you point
用**画笔**涂抹(大小、羽化、流量、密度、擦除),放置带可拖动控制点的**线性渐变**和**径向渐变**,
或者按**亮度范围**、**颜色范围**、**天空**、**主体**和**背景**来选择。用**添加/减去/相交**组合各个组件,
任意反相,每个蒙版还能调 15 项局部调整(色温、色调、曝光、对比度、高光、阴影、白色色阶、黑色色阶、纹理、
清晰度、去朦胧、色相、饱和度、锐化、降噪)以及一个总体的数量。
<p align="center">
<img src="docs/images/masking.jpg" alt="蒙版面板中有一个线性天空蒙版和一个径向阳光光晕蒙版;径向渐变在湖景照片上以红色叠加层画在太阳周围" width="100%">
<br>
<sub>叠加在线性「天空」蒙版之上的径向「阳光光晕」蒙版(色温 +40、曝光 +0.50)。<i>照片来自 LightCraft 程序生成的演示图库。</i></sub>
</p>
<br>
## Presets, profiles & the Color Mixer
内置十八个手工制作的预设(*Golden Hour、Teal & Orange、Faded Matte、Selenium Tone、Crisp
Landscape* 等等),每个都有 0 到 200 % 的**数量**滑块。你可以从任意一组设置保存自己的预设、标记收藏,
并在整个选区之间只复制、粘贴或同步你选定的那些分组。
<p align="center">
<img src="docs/images/presets-mixer.jpg" alt="预设列按 B&amp;W、Color、Film、Landscape、Portrait 和 Style 分组,旁边是 Color 面板,8 色段颜色混合器打开在紫色上" width="100%">
<br>
<sub>预设列与 8 色段颜色混合器并排。<i>照片来自演示图库。</i></sub>
</p>
<table>
<tr>
<td width="50%" valign="top">
### ✂️ Crop, straighten & geometry
自由或锁定的长宽比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖动旋转拉直并始终把最大的裁剪框
保持在画面内,三分线/网格/黄金比例叠加,翻转和 90° 旋转,外加手动垂直、水平、旋转、长宽比、
缩放和位移变换。
</td>
<td width="50%" valign="top">
### ⚫️ Black & White
一键(<kbd>V</kbd>)转为单色,配合 8 色段**黑白混合**压暗天空或让树叶发亮。
之后再用颜色分级调出硒调、棕褐或分离色调的印相效果。
</td>
</tr>
<tr>
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《特顿山脉与蛇河》上的裁剪工具:一个略微旋转的 16:9 裁剪框,带三分线叠加,右侧是几何滑块"><br><sub>一个 16:9 的裁剪框拉直了 +2.5°,带三分线叠加。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的分屏修改前后视图:左边是彩色,右边是带颗粒和暗角的黑白"><br><sub>分屏视图:左边是彩色原图,右边是带清晰度、暗角和颗粒的黑白。<i>演示图库。</i></sub></td>
</tr>
</table>
<br>
## Organize everything
一个不碍事的图库:**所有照片**、**最近添加**、**留用**、**按日期**、嵌套在**文件夹**里的**相册**,
以及**最近删除**。用 <kbd>0</kbd>–<kbd>5</kbd> 评星,用 <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd> 加旗标,用 <kbd>6</kbd>–<kbd>9</kbd> 加颜色标签。搜索支持字段:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每个视图都能按拍摄日期、
导入日期、编辑日期、名称、星级、大小或随机排序(一种稳定的乱序;「视图 → 排序 → 重新乱序」可换一个顺序)。
两端对齐的**照片网格**和**正方形网格**视图都做了虚拟化,
所以无论是四十张还是四万张照片都同样顺滑。
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 张演示照片的两端对齐照片网格,带星级和旗标,侧边栏是 Travel 2026 文件夹下嵌套的相册"><br><sub>照片网格,相册嵌套在文件夹里,带星级和旗标。<i>演示图库。</i></sub></td>
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="正方形网格,显示四张公有领域展示照片及其星级和留用旗标"><br><sub>四张公有领域展示照片的正方形网格。</sub></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的信息面板,显示文件名、2400 × 2400 的 JPEG 尺寸、星级、标题字段和相机元数据"><br><sub>信息面板:文件、尺寸、星级、标题、题注、版权和相机元数据。<i>NASA/比尔·安德斯,《地出》,阿波罗 8 号,1968(公有领域)。</i></sub></td>
</tr>
</table>
<br>
## Built for agents
LightCraft 里的每一个菜单项、滑块、画笔笔触、裁剪手柄和按键都是一个**命令**,拥有稳定的 ID 和
JSON 参数。界面、键盘、CLI、JSON-lines 控制通道和一个 **MCP 服务器**全都通过同一个入口分发。
智能体可以挑片、显影、给天空加蒙版并导出,而且能*看到*结果。
```sh
lightcraft --control 7980 ~/Pictures/trip
```
```jsonc
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 按名称操作任意控件
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
{"method": "ui.screenshot", "params": {"path": "after.png"}}
```
- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、
默认值和当前值。
- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端),
同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着
到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。
```sh
cargo build --release -p lightcraft-cli
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 无界面
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 驱动运行中的应用
```
- **可脚本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
path=out.jpg longEdge=2048` 可以运行任意命令链(无界面、在已保存的图库上,或对正在运行的应用),
每条命令打印一行 JSON 结果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
- **一切皆可撤销**,包括智能体的操作:一次滑块拖动(或脚本里一连串更新)算一步撤销。
- **联系表(原生应用/CLI):** 选中照片,然后 **文件 → 联系表 PDF…**。可选择 A4 或 Letter、横向、
行数和列数以及文件名题注;保存分页 PDF,再用你的 PDF 阅读器打印。导出使用当前编辑,每张照片完整放入
而不再次裁剪,以 150 dpi 和 sRGB 渲染。后台导出支持取消,并保护原文件和 XMP 附属文件。从 CLI 调用:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` 还接受显式的 `ids`、`landscape=true` 和 `paper=letter`。只有当整个 PDF 生成成功后,
它才会替换已有的输出文件。限制:1000 张照片、100 页、256 MiB;题注使用与水印相同的字体覆盖来栅格化
(中日韩文字需要 craft-fonts),过长的名称会在中间省略。
这是一套联系表工作流,
不是打印驱动或自定义拼版编辑器。
- **每个控件都可寻址**(`ui.widgets`)且可按名称点击,所以智能体操作的是真实界面,而不是绕到侧门。
- 本 README 中的截图全部由 [`docs/showcase/`](docs/showcase/) 脚本端到端生成。
协议参考:[docs/control-protocol.md](docs/control-protocol.md)。
<br>
## Fast, native, private
- **纯 Rust,无 C。** 我们自己的 RAW 解码器(DNG、佳能 CR2/CR3、索尼 ARW、尼康 NEF、富士 RAF 含 X-Trans、
松下 RW2/徕卡 RWL、宾得 PEF、奥林巴斯 ORF)、我们自己的色彩科学、我们自己的管线。JPEG、PNG、TIFF、WebP、
PSD 合成图和 JPEG XL 现在就能打开;加 `--features heif` 还能打开 HEIC/HEIF 照片(HEVC 是构建时的选择,
与 PhotoCraft 中一致)。
- **场景参考与宽色域。** 内部使用线性 Rec.2020 浮点、Bradford 适应的白平衡、用色域映射代替裁剪,
raw 走胶片式肩部,未改动过的 JPEG 则像素级直通。
- **分辨率无关的编辑。** 半径和画笔大小都相对于图像,所以 400 px 的预览、你的 5K 显示器和 60 MP 的导出
看起来完全一致。
- **GPU 加速,与 CPU 一致。** 整条显影管线都作为 wgpu 计算内核运行(Metal/Vulkan/DX12),
并与 CPU 管线比对到 1/255 以内。在 24 MP raw 上(Apple M4 Pro):一次滑块更新约 4 ms 重新渲染,
冷启动 2.5 MP 放大视图约 30 ms,全尺寸导出约 0.3 s(含并行 JPEG 编码)。
没有 GPU 时同一条管线在所有 CPU 核心上运行,只重做受滑块影响的那些阶段。批量导出会并排渲染多张照片,
所以一张的解码和编码能与另一张的渲染重叠。
- **即时挑片。** 打开一张 raw 会在约 0.1 s 内显示其内嵌的相机预览或缓存渲染,完整渲染随后跟上
(24 MP 约 0.2–0.5 s)。上一张和下一张会在后台准备好,因此翻阅一次拍摄时每张约 50 ms。
- **后台渲染。** 一个工作线程池在 UI 线程之外渲染放大视图、修改前后对比和每一个可见缩略图:
拖动时用草稿,松手后出全质量。
- **本地优先。** 没有账号、没有云、没有遥测、没有订阅。你的目录是一份只追加的可读操作日志,
可以 diff、备份或重放。
<br>
## Feature status
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
| 领域 | 状态 |
|---|---|
| 图库:相册、文件夹、智能相册、堆叠(含自动堆叠)、虚拟副本、星级、旗标、标签、过滤器栏、搜索、排序、网格、胶片窗格 | ✅ |
| 挑片:比较(同步缩放)和筛选视图、自动前进、即时预览 | ✅ |
| Light、Color、Effects(暗角样式)、色调曲线(+ 细化饱和度、目标调整)、颜色混合器(+ 目标调整)、点颜色、颜色分级、校准、黑白 | ✅ |
| 蒙版:画笔、线性、径向、亮度/颜色范围、添加/减去/相交 | ✅(AI 主体/天空目前使用经典启发式算法) |
| 裁剪、拉直工具 + 自动拉直、翻转、旋转、长宽比、叠加参考线 | ✅ |
| 配置文件(Color、Neutral、Vivid、Landscape、Portrait、Monochrome:我们自己的风格)、预设、版本、历史记录、复制/粘贴/同步设置 | ✅ |
| 相机色彩:DNG 文件使用其自带矩阵 | ✅ DNG · 🟡 我们自己的索尼/富士配置文件;缺少实测校准数据库 |
| 原生 macOS 菜单栏(由命令注册表生成)、控制通道 + 每个控件可寻址、无界面 UI 快照 | ✅ |
| RAW:DNG、CR2、CR3(无损 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未压缩 + 无损/有损压缩)、富士 RAF(未压缩 + 无损/有损压缩,Bayer + X-Trans)、松下 RW2/徕卡 RWL/Panasonic RAW(所有 raw 格式,从 DMC-LX1 到 DC-S1RM2)、宾得 PEF、奥林巴斯 ORF(未压缩)、三星 SRW(未压缩);所有格式的嵌入预览,含 CR3 | 🟡 · CR3、压缩 ORF 和压缩 SRW 解码 ⬜ |
| 细节:锐化、亮度 + 色彩降噪 | ✅ · AI 降噪、超分辨率 ⬜ |
| 污点去除/修复/仿制(自动来源)、可视化斑点、红眼和宠物眼(自动瞳孔检测、眼神光) | ✅ · 内容识别填充、污点图钉编辑 🚧 |
| 导出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸调整、文件大小上限、输出锐化、命名模板、批处理、元数据策略、文字或图像水印 | ✅ |
| HDR:带余量上限的 HDR 编辑、SDR 呈现、可视化 HDR;导出为 ISO 21496-1 增益图 JPEG、PQ AVIF 或 32 位浮点 TIFF | ✅ · HDR 显示 ⬜ |
| 图库持久化(防崩溃的操作日志 + 快照、后台压缩、保存失败会上报)、磁盘缩略图缓存 | ✅ |
| 导入:在原位置添加/复制/移动、重命名与文件夹模板、设备、重复检测、监视文件夹;本地文件夹浏览 | ✅ |
| MCP 服务器(无界面或运行中的应用、持久化图库)、CLI、控制通道 | ✅ |
| XMP 附属文件(读取/写入、自动写入)、读取 `crs:` 显影设置、预设文件(`.lcpreset`、XMP 预设) | ✅ |
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
<br>
## Quick start
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # 打开你的图库(~/Pictures/LightCraft Library;新图库会先带演示照片)
cargo run --release -p lightcraft -- ~/Pictures/trip # 导入你的照片(扫描文件夹,跳过重复项)
cargo run --release -p lightcraft -- --memory # 用完即弃的内存演示会话(不写入任何内容)
cargo run --release -p lightcraft -- --control 7980 # 带自动化通道
cargo xtask web --serve # 在浏览器中运行同一个应用:http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt、clippy、测试、分层、wasm 检查
```
CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一个构建任务
(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建,
而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的
完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
不指定它时,LightCraft 构建和运行的方式完全相同,只是中文和日文文字没有字形。字体从不提交到
本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。
**界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 →
语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。
**日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log`
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),从不写进图库。
从桌面菜单或 Dock 启动没有终端,所以请把这个文件附在错误报告里(**帮助 ▸ 打开日志
文件夹** 会在文件管理器中显示它)。每次启动会把
上一个日志移到 `lightcraft.1.log`,再上一个移到 `lightcraft.2.log`,这样崩溃那次的日志能在下次启动后
保留下来;文件到 16 MiB 停止增长,`--version` 和 `--help` 不写日志,带
`LIGHTCRAFT_NO_PREFS` 运行时只写到标准错误。默认情况下 LightCraft 自己的 crate 记到 `info`,其他都记到
`warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行为和以前一样(LightCraft 自己的 crate 用该级别,其余只记警告和
错误;其他值:只记警告和错误),并且优先于 `RUST_LOG`,后者会把默认值替换为
env_logger 风格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
`*` 结尾的指令覆盖所有以它开头的目标,如 `lightcraft*=debug`)。panic 也会记录在那里,
并且仍然写在临时文件夹里的 `lightcraft-panics.log`。日志器是 `apps/lightcraft/src/logging.rs`。
网页版需要 `wasm32-unknown-unknown` target 和匹配的 `wasm-bindgen` CLI
(`cargo xtask web` 会打印确切的安装命令);见 [docs/web.md](docs/web.md)。
**Nix** 会构建桌面应用和 `lightcraft-cli`(Nix 构建总是包含 craft-fonts 输入,所以日文
文字有字形):
```sh
nix run github:storytold/lightcraft # 桌面应用
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
nix develop github:storytold/lightcraft # rust 工具链 + 原生依赖 + 字体
```
在 flake 配置中(NixOS、home-manager、nix-darwin):
```nix
# flake.nix
inputs.lightcraft.url = "github:storytold/lightcraft";
# 可选:使用你自己的 nixpkgs,而不是 LightCraft 锁定的那个
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
# 然后在 NixOS 或 home-manager 模块中(`inputs` 在作用域内):
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 让 `pkgs.lightcraft` 可用
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
```
`nix build` 会安装与 .deb/.rpm 相同的 desktop 文件、hicolor 图标和 AppStream 元数据,并运行
`cargo test --workspace` 作为它的检查阶段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳过)。
**键盘:** <kbd>G</kbd> 网格 · <kbd>D</kbd> 细节 · <kbd>E</kbd> 编辑 · <kbd>C</kbd> 裁剪 · <kbd>M</kbd> 蒙版 ·
<kbd>Shift</kbd>+<kbd>P</kbd> 预设 · <kbd>\\</kbd> 原图 · <kbd>Y</kbd> 修改前后 · <kbd>Z</kbd> 缩放 ·
<kbd>J</kbd> 溢出警告 · <kbd>⌘Z</kbd> 撤销 · <kbd>⌘/</kbd> 全部快捷键。
## How it's built
一个引擎优先的 Cargo 工作区,由小而经过测试的 crate 组成,并强制分层(`cargo xtask layers`):`geom`、
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 的存在。`mcp` 和应用(`lightcraft`、
`lightcraft-cli`)坐落在 `engine` 之上。
## Contributing
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依据公开规范和黑盒观察来工作。
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
- **纯 Rust**、强制 crate 分层、一切皆命令,并且每次提交前 `cargo xtask ci` 必须通过
(一个提交一个任务 ID)。
- **绝不崩溃。** 非测试代码返回错误而不是 panic:不用 `unwrap()`、`expect()`、`panic!` 或
`unsafe`,对任何来自输入的数据都做受检索引,并且每个崩溃修复都带一个回归测试。细节见
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
有问题、想法,或者想先讨论一个 bug?来 [Discord](https://discord.gg/artcraft)。
<br>
## Downloads
**第一次用 LightCraft?** 从 [getartcraft.com 的 LightCraft 页面](https://getartcraft.com/apps/lightcraft)下载。那是最简单的安装方式。
**想要特定的构建或格式?** 在 GitHub 上,[最新发布](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有构建,[全部发布](https://github.com/storytold/lightcraft/releases)则有更早的版本及其说明。文件名中的 `<ver>` 是版本号,`SHA256SUMS.txt` 列出了每个文件的校验和。
### Windows
| 构建 | 安装程序 | 便携版 |
|---|---|---|
| x64(64 位 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
| arm64(骁龙及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86(32 位) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认
`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
并可选择启动它。无人值守安装:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
### macOS
| 构建 | 文件 | 说明 |
|---|---|---|
| 应用,通用(Apple 芯片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已签名并公证 |
| 命令行工具,通用 | `lightcraft-cli-<ver>-macos-universal.zip` | 已签名并公证 |
### Linux
| 格式 | x86_64 | aarch64 (ARM64) | 说明 |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 随处可运行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 文件)自我更新 |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| 压缩包 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解压到任意位置 |
### FreeBSD
| 构建 | 文件 |
|---|---|
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
### 网页版(WebAssembly)
| 构建 | 文件 | 说明 |
|---|---|---|
| 静态站点 | `lightcraft-web-<ver>.zip` | 在现代浏览器中运行;托管到任意静态服务器 |
<br>
## The Crafting Apps
LightCraft 是 **Crafting Apps** 之一:来自 [ArtCraft](https://getartcraft.com/) 团队的免费开源创作工具,
每一个都用 Rust 从零编写,每一个都能独立使用。
| | 应用 | 用途 | 代码 | 了解更多 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 图像编辑:图层、蒙版、文字和真正的 PSD 文件 | [GitHub](https://github.com/storytold/photocraft) | [网站](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 矢量插画 | [GitHub](https://github.com/storytold/vectorcraft) | [网站](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 视频剪辑、调色与声音 | [GitHub](https://github.com/storytold/filmcraft) | [网站](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片图库与 raw 显影 · 你在这里** | [GitHub](https://github.com/storytold/lightcraft) | [网站](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 阅读、整理和保护 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [网站](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 动态图形与视觉特效 | [GitHub](https://github.com/storytold/effectcraft) | [网站](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 页面排版与出版 | [GitHub](https://github.com/storytold/designcraft) | [网站](https://getartcraft.com/apps/designcraft) |
以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
我们的 Discord 是各种创作者聚集的地方:画画的人、拍照的人、做动画的人、剪片子的人、
排版的人,还有仍在摸索自己喜欢做什么的人。分享你在做的东西,
寻求帮助,告诉我们哪里坏了,或者告诉我们你希望这些工具能做什么。
无论你用什么媒介,无论你做了多久,这里都欢迎你。
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
</p>
<br>
## License and credits
LightCraft 采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任你选择。
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. 必需的声明见 [NOTICE](NOTICE)。
随附的字体、图标、图像和其他资源保留各自的开放许可;每一项都在
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、来源和许可。
展示照片是经 Wikimedia Commons 使用的公有领域作品:安塞尔·亚当斯,*《特顿山脉与蛇河》*
(1942,美国国家档案馆);多萝西娅·兰格,*《移民母亲》*(1936,美国国会图书馆);比尔·安德斯/NASA,
*《地出》*(1968);NASA,*《蓝色弹珠》*(1972)。演示图库由 LightCraft 程序生成。界面字体:
Inter(SIL OFL 1.1)。用 [craft-fonts](https://github.com/storytold/craft-fonts) 构建的版本(所有正式发布版)
还会内嵌它的中文和日文字体(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列在其
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有图标均为原创。
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名称、字标和标志是
ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改的情况下,作为
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支和修改版本必须移除它们。
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
+144 -214
View File
@@ -1,242 +1,172 @@
# LightCraft roadmap
Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock
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.
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
## Where we stand
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
*Honest assessment, 2026-10-08. 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.*
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
[docs/target-app-parity.md](docs/target-app-parity.md).
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), 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.
## Headline numbers
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.7% | 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 | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. 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, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **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), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; 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; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
| | Value | Kind |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); embedded distortion is supported for Panasonic / Leica RW2 and the Sony ILCE-7RM4A (Bayer ARW, 24–105 / 200–600 tested); other lens metadata needs coverage and validation |
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open |
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
| **Feature breadth** (weighted by use) | **~77%** | estimated |
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
| **Mainstream practitioner** | **~49%** (44–54%) | estimated |
| **Essentials user** | **~64%** (59–69%) | estimated |
| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
## Where we're going
Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
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. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF
since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to
measured spectral sensitivities (rawtoaces-data); then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, 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): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW
embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata.
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
Hours are calibrated on this repo's PR history; see
[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated).
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).
## By dimension
Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour
labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a
problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is
the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows.
| Dimension | Ready | Breadth | Remaining | Doc |
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) |
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
## Features
## Milestones
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|---|---|---|---|---|
| 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 + 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 ⬜) |
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
| 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, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| 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 ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-08; 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 | 2026-10-02 |
| Area | Ready | Breadth | Remaining |
|---|---:|---:|---:|
| P0 (core) | **98.2%** | 200 | 98.7% |
| P1 (important parity) | **95.6%** | 149 | 94.4% |
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
| **All in-scope rows** | **79.7%** | 509 | 75.6% |
| Import and library | 80% | 90% | 25–45 h |
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
| Global develop adjustments | 70% | 93% | 30–60 h |
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
| Masking | 45% | 67% | 60–110 h |
| Remove, heal, Enhance | 30% | 45% | 70–130 h |
| Presets, profiles, versions, sync | 85% | 95% | 8–15 h |
| Export | 75% | 89% | 15–30 h |
| Merge and HDR | 60% | 85% | 20–35 h |
| Views, compare, zoom, culling | 70% | 90% | 15–30 h |
| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h |
| Video | 0% | 0% | 25–45 h |
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.
Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features).
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
## Languages
**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 ≈ 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):
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
| Work package | Tracker rows | Agent-hours | Risk |
| Language | Code | Translated | Status |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | 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 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** | | **≈ 270–505** | |
| English | en | 100% | full (source) |
| Simplified Chinese | zh-hans | 87% | partial |
| Spanish | es | 86% | partial |
| Hindi | hi | 0% | none (no Devanagari shaping) |
| Arabic | ar | 0% | none (no RTL / shaping) |
| French | fr | 88% | partial |
| Portuguese (Brazil) | pt-br | 85% | partial |
| Indonesian | id | 0% | none |
| Japanese | ja | 87% | partial |
| German | de | 91% | partial |
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
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).
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
**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.
## Upcoming
## Totals
Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md).
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
2. CR3 for every Canon body (25–45 h)
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
4. Full-resolution zoom (8–15 h)
5. AI masks: Subject / Sky / People (40–70 h + model decision)
6. Compressed ORF and HE NEF (28–55 h)
7. Lens profile database of our own (40–80 h)
8. Tests in CI on macOS, Windows and Linux (6–12 h)
9. Per-model raw verification (20–35 h)
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|---|---:|---:|
| 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 |
## Progress log
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).
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
verified; RAW decoding 76% → 77%; headline numbers unchanged.
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with
the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h);
alpha gate passes.
- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist
81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
(#646), Lightroom-like Auto (#628), activity stack.
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact
full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting
([cr3.md](docs/cr3.md)).
- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays
verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4
profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34.
AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI.
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on
178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in
pure Rust and People view.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import,
folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background
compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment
and tracker rows for camera colour, camera coverage and render fidelity.
- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones
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-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
README showcase. ≈ 8 h elapsed.
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
ecosystem) complete.
## Risks that coding hours alone don't retire
## Revision history
- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical
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.
- **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. Retuning changes existing edits, so it
ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated.
## Raw format coverage and known gaps
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted
`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
## Lightroom Classic catalog migration
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
archived before catalog mutation. Missing originals remain available for relinking.
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
history/snapshots remain source data, and smart collections import current membership. The Lightroom
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
## Log
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 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-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
- 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.
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos. On 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open.
## Chinese and Japanese interfaces, and text watermarks
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels
and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English.
CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. 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.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha |
| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
| 2026-10-02 | major | Parity estimate and effort table |
| 2026-09-30 | major | Created |
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-denoise = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-mcp = { workspace = true }
lightcraft-ui-egui = { workspace = true }
+74 -2
View File
@@ -7,6 +7,7 @@
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
//! lightcraft-cli synth-merge hdr|panorama -o DIR
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
@@ -51,17 +52,19 @@ USAGE:
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
--compact list only the helper tools (every command stays reachable via run_command)
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
same encoder as the app's Export dialog. Options:
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
--settings FILE merge a partial develop-settings JSON file
--preset ID apply a preset (see `commands`/presets.list)
--size N long edge in pixels (default: full size, cropped)
--quality Q JPEG/AVIF quality 1..100 (default 92)
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
--opt format=original (copy + XMP sidecar) --opt metadata=none
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
the file to Apple Photos, into that album)
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
Options:
@@ -85,6 +88,18 @@ USAGE:
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
lightcraft-cli synth-merge hdr|panorama -o DIR
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
lightcraft-cli denoise-serve [OPTIONS]
Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=<this host>:7990 and
LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings,
on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN
address (e.g. the Tailscale IP) or firewall the port to it. Options:
--model ID an installed model other than the selected one
--models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's)
--listen ADDR address:port to listen on (default 127.0.0.1:7990)
--token-env VAR environment variable holding the shared token, ≥ 16 characters
(default LIGHTCRAFT_DENOISE_TOKEN)
--run-on WHERE auto (default), gpu or cpu
--max-connections N concurrent clients (default 8)
lightcraft-cli commands [--json] list every command id with its parameters
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
@@ -115,6 +130,9 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
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 @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
),
e => format!("{dir}: {e}"),
}
}
@@ -145,6 +163,7 @@ fn main() -> ExitCode {
Some("merge") => merge(&args[1..]),
Some("synth-merge") => synth_merge(&args[1..]),
Some("controls") => controls(&args[1..]),
Some("denoise-serve") => denoise_serve(&args[1..]),
Some("calibrate") => calibrate(&args[1..]),
Some("--version" | "-V" | "version") => {
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
@@ -681,6 +700,8 @@ fn render(args: &[String]) -> Result<(), String> {
o.format = ExportFormat::parse(&ext)
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
}
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
let sidecars: Vec<(String, &Vec<u8>)> =
@@ -695,6 +716,12 @@ fn render(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli: wrote {sc}");
}
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
if let Some(photos) = after.run(&[json!({"path": output})], false) {
if let Some(err) = photos.get("error").and_then(Value::as_str) {
return Err(format!("render: {output} was written, but {err}"));
}
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
}
Ok(())
}
@@ -897,3 +924,48 @@ fn controls(args: &[String]) -> Result<(), String> {
}
Ok(())
}
/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`).
#[cfg(feature = "denoise")]
fn denoise_serve(args: &[String]) -> Result<(), String> {
use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve};
use lightcraft_engine::denoise::RunOn;
let mut model: Option<String> = None;
let mut dir: Option<String> = None;
let mut listen = format!("127.0.0.1:{DEFAULT_PORT}");
let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string();
let mut run_on = RunOn::Auto;
let mut max_connections = 8usize;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()),
"--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()),
"--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(),
"--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(),
"--run-on" => {
let v = take_value(args, &mut i, "--run-on")?;
run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?;
}
"--max-connections" => {
max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")?
}
other => return Err(format!("denoise-serve: unknown option `{other}`")),
}
i += 1;
}
let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?;
let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?;
let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?;
eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device);
if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") {
eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)");
}
let opts = ServerOptions { token, max_connections, ..Default::default() };
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
}
#[cfg(not(feature = "denoise"))]
fn denoise_serve(_: &[String]) -> Result<(), String> {
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
}
+66
View File
@@ -577,3 +577,69 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
assert!(image.exists());
}
}
/// `denoise-serve` serves the selected installed model; a client greets it and gets the CPU's answer for a tile
/// (within half-float rounding on the wire), and a wrong token or a missing token variable is refused.
#[cfg(feature = "denoise")]
#[test]
fn denoise_serve_runs_tiles_for_another_computer() {
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_denoise::remote::Remote;
use lightcraft_denoise::run::TileRunner;
let models = tmp("denoise-models");
let _ = std::fs::remove_dir_all(&models);
let dir = models.join("synthetic");
std::fs::create_dir_all(&dir).unwrap();
let manifest = DenoiserManifest {
id: "synthetic".into(),
name: "Synthetic".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: "made for a test".into(),
domain: Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
};
let onnx = dir.join("model.onnx");
std::fs::write(&onnx, lightcraft_denoise::synthetic::unet_onnx(64, 8, 2, 7)).unwrap();
std::fs::write(dir.join("denoise-model.json"), serde_json::to_vec(&manifest).unwrap()).unwrap();
std::fs::write(models.join("settings.json"), r#"{"model": "synthetic"}"#).unwrap();
let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap();
// without its token the server doesn't start
let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let addr = format!("127.0.0.1:{port}");
let token = "cli-test-token-0123456789";
let mut child = Command::new(BIN)
.args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"])
.env("LIGHTCRAFT_DENOISE_TOKEN", token)
.stderr(Stdio::piped())
.spawn()
.unwrap();
// wait for the banner
let mut err = BufReader::new(child.stderr.take().unwrap());
let mut banner = String::new();
err.read_line(&mut banner).unwrap();
assert!(banner.contains("synthetic 1") && banner.contains(&addr), "{banner}");
let remote = Remote::new(&addr, token, &sha, 64);
let info = remote.hello().unwrap();
assert!(info.device.starts_with("CPU"), "{info:?}");
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
let got = remote.try_run(&input).unwrap().unwrap();
let want = lightcraft_denoise::runtime::CpuRunner::load(&onnx, &manifest).unwrap().run(&input).unwrap();
let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs()));
let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
assert!(worst < 5e-3, "remote vs local: {worst}");
assert!(Remote::new(&addr, "a-wrong-token-000000", &sha, 64).hello().is_err());
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&models);
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
//!
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
//! `docs/web.md`). Differences from the desktop host:
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
+1
View File
@@ -180,6 +180,7 @@ pub fn mime_for(name: &str) -> &'static str {
Some("tif" | "tiff") => "image/tiff",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("jxl") => "image/jxl",
_ => "application/octet-stream",
}
}
+4
View File
@@ -170,6 +170,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
Services {
picker: None,
pick_display_profile: None,
screen_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
@@ -227,11 +228,14 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
reveal: None,
log_file: None,
open_with: None,
check_editor: 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())
})),
pick_folder: None,
// the browser reports prefers-color-scheme to egui
system_theme: None,
}
}
+47 -2
View File
@@ -36,6 +36,8 @@ pub struct WireJob {
pub raw: bool,
#[serde(default)]
pub relative_wb: bool,
#[serde(default)]
pub legacy_relative_wb: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
@@ -74,6 +76,7 @@ impl WireJob {
max_edge,
raw: job.info.raw,
relative_wb: job.info.relative_wb,
legacy_relative_wb: job.info.legacy_relative_wb,
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
@@ -97,6 +100,7 @@ impl WireJob {
as_shot_tint: self.as_shot_tint,
lens: self.lens,
relative_wb: self.relative_wb,
legacy_relative_wb: self.legacy_relative_wb,
..Default::default()
}
}
@@ -184,13 +188,18 @@ impl WorkerCore {
}
};
let info = src.info_or(job.info());
Ok(match &job.stages {
let mut rendered = match &job.stages {
Some(view) => {
let st = self.stages.entry(view.clone()).or_default().clone();
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
}
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
})
};
// an HDR edit's histogram shows its HDR range (as on native)
if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) {
rendered.histogram = h;
}
Ok(rendered)
}
}
@@ -332,11 +341,46 @@ impl CacheWatch {
}
}
/// Hands out the positions that render workers will have in the main thread's worker list.
/// A worker that fails to start gets no position, so the position a callback captures stays the
/// index of its own worker in that list (#229).
#[derive(Debug, Default)]
pub struct WorkerSlots {
next: usize,
}
impl WorkerSlots {
/// The position the worker being started will have, if it comes up.
pub fn peek(&self) -> usize {
self.next
}
/// The worker at [`Self::peek`] came up and was added to the list.
pub fn commit(&mut self) {
self.next += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_engine::Session;
// Issue #229: a failed worker attempt must not shift the positions of the workers after it
#[test]
fn a_failed_worker_attempt_leaves_no_gap_in_the_slots() {
let mut slots = WorkerSlots::default();
let mut taken = Vec::new();
for attempt_ok in [false, true, true, true] {
let i = slots.peek();
if attempt_ok {
taken.push(i);
slots.commit();
}
}
assert_eq!(taken, vec![0, 1, 2]);
}
#[test]
fn clearing_index_changes_disk_namespace_and_survives_restart() {
let key = lightcraft_preview::hash_bytes(b"thumbnail");
@@ -421,6 +465,7 @@ mod tests {
max_edge: 512,
raw: false,
relative_wb: false,
legacy_relative_wb: false,
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
+19 -16
View File
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::backend::Backend;
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, WorkerSlots, thumb_storage_key};
/// Storage key of the thumbnail index.
pub const THUMB_INDEX: &str = "thumbs/index.json";
@@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
Ok((r, stored)) => {
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
let h = &r.histogram;
let hist = Uint32Array::new_with_length(4 * 256 + 1);
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
}
hist.set_index(1024, h.total);
let hist = Uint32Array::from(h.to_words().as_slice());
set(&out, "ok", &true.into());
set(&out, "w", &(r.image.width as u32).into());
set(&out, "h", &(r.image.height as u32).into());
@@ -245,14 +241,17 @@ impl Workers {
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
return w;
}
for i in 0..n {
let mut slots = WorkerSlots::default();
for attempt in 0..n {
// The callbacks name their worker by its place in `workers`; a failed attempt takes none.
let i = slots.peek();
let opts = web_sys::WorkerOptions::new();
opts.set_type(web_sys::WorkerType::Module);
opts.set_name(&format!("lightcraft-render-{i}"));
opts.set_name(&format!("lightcraft-render-{attempt}"));
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
Ok(x) => x,
Err(e) => {
log::warn!("render worker {i}: {e:?}");
log::warn!("render worker {attempt}: {e:?}");
continue;
}
};
@@ -268,10 +267,15 @@ impl Workers {
set(&init, "module", &module);
set(&init, "store", &store.into());
if let Err(e) = worker.post_message(&init) {
log::warn!("render worker {i}: {e:?}");
log::warn!("render worker {attempt}: {e:?}");
// It never joins the list, so it must not answer to the index of the next worker.
worker.set_onmessage(None);
worker.set_onerror(None);
worker.terminate();
continue;
}
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
slots.commit();
}
w
}
@@ -291,6 +295,7 @@ impl Workers {
level: b.level,
key: b.key,
rendered: Err(msg),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
@@ -317,12 +322,8 @@ impl Workers {
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
match Rgba8::from_bytes(wd, ht, &rgba) {
Some(image) if hist.len() == 1025 => {
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
Ok(Rendered { image, histogram, deep: None })
}
match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {
(Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),
_ => Err("render worker: bad image".to_string()),
}
} else {
@@ -378,6 +379,7 @@ impl Workers {
level: b.level,
key: b.key,
rendered,
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
@@ -431,6 +433,7 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
rendered: Ok(Rendered { image, histogram, deep: None }),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
+12 -1
View File
@@ -23,17 +23,28 @@ lightcraft-sysmem = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
# probing the window's renderer before it opens (`window_gpu`)
pollster = { workspace = true }
log = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { workspace = true }
objc2 = { workspace = true }
objc2-app-kit = { workspace = true }
objc2-foundation = { workspace = true }
+198
View File
@@ -0,0 +1,198 @@
//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux
//! desktops whose windowing layer reports no theme to egui (many Wayland compositors).
//!
//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker
//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a
//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer.
//! Elsewhere egui's own report (`Context::system_theme`) is used.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
/// another variant inside it.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Wake the first frame after a slow initial Read as well as subsequent real changes.
#[cfg(target_os = "linux")]
fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock<egui::Context>, code: u8) {
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
&& let Some(ctx) = wake.get()
{
ctx.request_repaint();
}
}
/// The host's system-appearance service (Linux), or `None` where egui's report is enough.
pub fn service() -> Option<lightcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, Ordering},
};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("appearance-portal".into())
.spawn(move || {
let proxy = portal_proxy();
// Subscribe before Read so a change during startup is queued rather than lost.
let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
publish(&worker_value, &worker_wake, code_of(initial));
let _ = ready.send(());
let Some(signals) = signals else { return };
// Blocks until the portal sends a signal: no polling.
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
publish(&worker_value, &worker_wake, code);
}
})
.is_err()
{
return None;
}
// The first frame shows the right theme when the portal answers quickly; a slow one
// only delays the switch (the worker wakes the UI when the value changes).
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn delayed_initial_read_repaints_only_on_change() {
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, AtomicUsize, Ordering},
};
let ctx = egui::Context::default();
let settle = |ctx: &egui::Context| {
for _ in 0..8 {
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
}
};
settle(&ctx);
let calls = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&calls);
ctx.set_request_repaint_callback(move |_| {
observed.fetch_add(1, Ordering::Relaxed);
});
let wake = OnceLock::new();
assert!(wake.set(ctx.clone()).is_ok());
let value = AtomicU8::new(0);
super::publish(&value, &wake, 2);
assert_eq!(value.load(Ordering::Relaxed), 2);
let after_change = calls.load(Ordering::Relaxed);
assert!(after_change > 0);
settle(&ctx);
let after_settle = calls.load(Ordering::Relaxed);
super::publish(&value, &wake, 2);
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_values_and_gsettings_output_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
let plain = zbus::zvariant::OwnedValue::from(1u32);
assert_eq!(super::portal_code(plain), Some(1));
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
assert_eq!(super::portal_code(wrong), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::parse_gtk_scheme(""), None);
for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] {
assert_eq!(super::decode(u32::from(super::code_of(theme))), theme);
}
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it,
//! or any other application the user named in Settings ▸ External Editing.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`).
const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT;
/// PhotoCraft's application id (Flatpak, desktop entry).
const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft";
/// How an editor is started.
#[derive(Debug, PartialEq)]
enum Launcher {
/// The system's default application for the file.
Default,
/// A program file, given the photo as its argument.
Program(PathBuf),
/// macOS: `open -a <app>` (an application name or bundle path).
MacApp(String),
/// The PhotoCraft Flatpak, with the file forwarded into its sandbox.
Flatpak,
}
/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default):
/// for PhotoCraft, that it is installed. The error says what to do instead.
pub fn check(app: &str) -> Result<(), String> {
launcher(app).map(|_| ())
}
/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside.
pub fn open_with(path: &str, app: &str) -> Result<(), String> {
let mut c = match launcher(app)? {
Launcher::Default if cfg!(target_os = "macos") => {
let mut c = Command::new("open");
c.arg(path);
c
}
Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path),
Launcher::Default => {
let mut c = Command::new("xdg-open");
c.arg(path);
c
}
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app, path]);
c
}
Launcher::Program(p) => {
let mut c = Command::new(p);
c.arg(path);
c
}
Launcher::Flatpak => {
let mut c = Command::new("flatpak");
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]);
c
}
};
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// Windows: open `path` in its default application through Explorer, given the path as its one
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
/// syntax (running what follows an `&` as a command).
fn windows_default_command(path: &str) -> Command {
let mut c = Command::new("explorer.exe");
c.arg(path.replace('/', "\\"));
c
}
fn launcher(app: &str) -> Result<Launcher, String> {
let app = app.trim();
if app.is_empty() {
return Ok(Launcher::Default);
}
if !app.eq_ignore_ascii_case(PHOTOCRAFT) {
return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) });
}
find_photocraft().ok_or_else(|| {
"PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string()
})
}
fn find_photocraft() -> Option<Launcher> {
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string());
if cfg!(target_os = "macos") {
// LaunchServices finds the app wherever it is; these are only the usual places
let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app"));
let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string())));
}
if cfg!(target_os = "windows") {
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None);
return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program);
}
// Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which
// registers one on first run), else the Flatpak
let on_path =
env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
let from_entry = || {
desktop_entry_dirs(env).into_iter().find_map(|d| {
let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?;
desktop_exec(&text).filter(|p| p.is_file())
})
};
if let Some(p) = on_path.or_else(from_entry) {
return Some(Launcher::Program(p));
}
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
flatpak.then_some(Launcher::Flatpak)
}
/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI
/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default
/// folders (per machine, then per user).
fn windows_candidates(install_dir: Option<String>, app_path: Option<String>, env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe")));
out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"'))));
for var in ["ProgramW6432", "ProgramFiles"] {
out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe")));
}
out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe")));
out
}
/// A string value from the Windows registry (`reg query`; `None` = the key's default value).
fn reg_query(key: &str, name: Option<&str>) -> Option<String> {
let mut c = Command::new("reg");
c.args(["query", key]);
match name {
Some(n) => c.args(["/v", n]),
None => c.arg("/ve"),
};
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
c.creation_flags(CREATE_NO_WINDOW);
}
let out = c.output().ok().filter(|o| o.status.success())?;
reg_value(&String::from_utf8_lossy(&out.stdout))
}
/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line.
fn reg_value(output: &str) -> Option<String> {
output.lines().find_map(|l| {
let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?;
Some(v.trim().to_string()).filter(|v| !v.is_empty())
})
}
/// The folders desktop entries live in (XDG base directories).
fn desktop_entry_dirs(env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
match env("XDG_DATA_HOME") {
Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)),
_ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))),
}
let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from));
dirs.into_iter().map(|d| d.join("applications")).collect()
}
/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped).
fn desktop_exec(entry: &str) -> Option<PathBuf> {
let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim();
let program = match exec.strip_prefix('"') {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
}
#[cfg(test)]
mod tests {
use super::*;
/// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not
/// through a shell.
#[test]
fn windows_default_open_goes_through_explorer_not_cmd() {
let path = r"C:/Edits/a & b ^c %PATH% | d.psd";
let c = windows_default_command(path);
assert_eq!(c.get_program(), "explorer.exe");
let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
}
#[test]
fn windows_lookup_prefers_the_recorded_install_folder() {
let env = |k: &str| match k {
"ProgramFiles" => Some(r"C:\Program Files".to_string()),
"LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()),
_ => None,
};
let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env);
let c: Vec<String> = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect();
assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}");
assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped");
assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}");
assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}");
assert_eq!(c.len(), 4);
}
#[test]
fn reg_query_output_is_read() {
let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n";
assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\"));
let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n";
assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe"));
assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None);
}
#[test]
fn desktop_entries_give_their_program() {
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage")));
assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft")));
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup");
assert_eq!(desktop_exec("[Desktop Entry]\n"), None);
let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string()));
assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications"));
assert!(dirs.contains(&PathBuf::from("/usr/share/applications")));
}
#[test]
fn other_editors_are_used_as_named() {
assert_eq!(launcher("").unwrap(), Launcher::Default);
assert_eq!(launcher(" ").unwrap(), Launcher::Default);
let named = launcher("/usr/bin/gimp").unwrap();
if cfg!(target_os = "macos") {
assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into()));
} else {
assert_eq!(named, Launcher::Program("/usr/bin/gimp".into()));
}
}
}
+275 -110
View File
@@ -23,11 +23,16 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
mod appearance;
mod control_server;
mod dialog_filter;
mod editors;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod screen_profile;
mod window_gpu;
use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
@@ -59,7 +64,7 @@ impl eframe::App for App {
// Quit from the menu bar: save exactly what closing the window saves (settings and
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
// process instead of closing the viewport while AppKit is terminating (PR #444).
eframe::App::on_exit(self);
eframe::App::on_exit(self, None);
std::process::exit(0);
}
self.0.logic(ctx);
@@ -71,7 +76,7 @@ impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self) {
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
if let Err(e) = self.1.save(&self.0) {
log::error!("{e}");
}
@@ -93,13 +98,16 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
#[cfg(test)]
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
}
c
window_gpu::base_options()
}
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
/// and `LIGHTCRAFT_NO_PREFS`.
fn window_files(in_memory: bool) -> window_gpu::Files {
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
}
/// Written while the GPU compute device is created, removed once that returned
@@ -183,6 +191,23 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => workspace.validate().is_err(),
Ok(None) => false,
Err(_) => true,
});
if recovered {
fields.remove("docking");
}
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
Ok((ui, recovered))
}
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
@@ -192,8 +217,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
return (None, Some(msg), true);
}
};
match serde_json::from_slice::<UiState>(&bytes) {
Ok(ui) => (Some(ui.sanitized()), None, false),
match decode_saved_ui(&bytes) {
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
Ok((ui, true)) => {
let msg = format!(
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
path.display()
);
(Some(ui.sanitized()), Some(msg), true)
}
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
(None, Some(msg), true)
@@ -270,6 +302,9 @@ impl PrefsWriter {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
workspace.validate()?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
@@ -348,9 +383,27 @@ fn explorer_select_command(path: &str) -> std::process::Command {
c
}
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
/// for Help ▸ Open Log Folder.
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
/// A file dialog owned by the app's window, or a plain one before the window exists.
fn file_dialog() -> rfd::FileDialog {
DIALOG_PARENT.get().cloned().unwrap_or_default()
}
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
let ctx = cc.egui_ctx.clone();
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
Services {
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
// stopped the window answering the compositor, which then reported the app as hung.
@@ -361,7 +414,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
let mut d = rfd::FileDialog::new();
let mut d = file_dialog();
if let Some(t) = &req.title {
d = d.set_title(t);
}
@@ -387,37 +440,17 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
})),
log_file: log_file.map(|p| p.display().to_string()),
pick_lightroom_catalog: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_folder: Some(Box::new(|| {
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("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())
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(editors::open_with)),
check_editor: Some(Box::new(editors::check)),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
@@ -445,7 +478,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
})),
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
@@ -454,14 +487,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.collect()
})),
pick_denoise_model: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default()
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
@@ -474,7 +507,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.collect()
})),
pick_model_file: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
.pick_file()
@@ -482,15 +515,20 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.unwrap_or_default()
})),
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
#[cfg(not(target_os = "macos"))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = rfd::FileDialog::new()
let mut d = file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
if let Some(dir) = display_profile_dir() {
@@ -499,7 +537,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
d.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()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
@@ -507,7 +545,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
.pick_file()
@@ -515,7 +553,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.unwrap_or_default()
})),
save_keyword_list: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
.set_file_name(name)
@@ -523,7 +561,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
@@ -533,7 +571,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.collect()
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
@@ -549,6 +587,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
system_theme: appearance::service(),
}
}
@@ -556,6 +595,8 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(s, problem)
}
@@ -608,6 +649,7 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
@@ -663,10 +705,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
fn main() -> eframe::Result {
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
if let Some(logger) = logger {
logging::record_panics(logger);
}
@@ -737,7 +785,14 @@ fn main() -> eframe::Result {
let gpu_crash = gpu_crash_check(in_memory);
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
lightcraft_engine::gpu::set_enabled(gpu_on);
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
// build egui's pipeline never kills the start-up
let win_files = std::sync::Arc::new(window_files(in_memory));
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
win_files.arm(window_renderer);
let win_files_app = win_files.clone();
let options = eframe::NativeOptions {
renderer,
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
.with_inner_size([1600.0, 1000.0])
@@ -749,73 +804,97 @@ fn main() -> eframe::Result {
.with_icon(app_icon())
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
.with_app_id(APP_ID),
wgpu_options: window_wgpu_options(),
wgpu_options,
..Default::default()
};
// the app's copy: `log_file` is still named in the message of a start that fails
let app_log_file = log_file.clone();
let started = eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
session.segmenter.dir =
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; LIGHTCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("LIGHTCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session = session.with_default_sam3_model();
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
#[cfg(target_os = "macos")]
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
app.notices.extend(window_notice);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
#[cfg(target_os = "macos")]
menu,
)))
}),
);
let menu =
(std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}));
// a panic that still escaped the renderer's start (the probe and error handler should
// prevent it): an error like any other failed start
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
if let Err(e) = &started {
// the renderer never came up: start again, which tries the next one in the chain
if window_gpu::relaunch_after_failure(&win_files) {
log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name());
return Ok(());
}
startup_failed(&e.to_string(), log_file.as_deref());
}
started
@@ -868,6 +947,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&home);
}
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
/// adds to Apple Photos on a worker (headless sessions wait instead).
#[test]
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
let (s, problem) = open_session(true, None, false);
assert!(problem.is_none());
assert!(s.apple_photos_background);
assert!(!lightcraft_engine::Session::new().apple_photos_background);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
@@ -904,6 +993,59 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn docking_survives_native_settings_file_reload() {
use lightcraft_ui_egui::docking::Panel;
let d = dir("docking-reload");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save_ui(&app.ui, false).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, false);
assert!(warning.is_none());
assert!(!keep);
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
restored_app.ui = restored.unwrap();
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
assert_eq!(
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
[70.0, 80.0, 310.0, 500.0]
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
let d = dir("future-docking");
let path = d.join("ui.json");
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
let mut saved = serde_json::to_value(&ui).unwrap();
saved["docking"] = docking;
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
let bytes = serde_json::to_vec(&saved).unwrap();
for set_aside in [false, true] {
std::fs::write(&path, &bytes).unwrap();
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
let loaded = loaded.unwrap();
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
assert_eq!(loaded.thumb_size, ui.thumb_size);
assert!(!loaded.filmstrip);
assert!(loaded.docking.is_none());
assert!(warning.unwrap().contains("Other settings were loaded"));
assert!(keep);
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
writer.save_ui(&loaded, false).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
}
}
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
#[test]
@@ -999,6 +1141,16 @@ mod tests {
}
}
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
#[test]
fn window_has_no_indirect_call_validation_pipeline() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
#[test]
@@ -1056,3 +1208,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}
+30
View File
@@ -0,0 +1,30 @@
//! The ICC profile of the screen showing LightCraft's window (macOS).
//!
//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's
//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so
//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in
//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the
//! previews are shown through this profile instead (`Services::screen_profile`).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSScreen};
/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None`
/// when it isn't known: off the main thread, or a screen without a usable profile.
pub fn icc() -> Option<Vec<u8>> {
let mtm = MainThreadMarker::new()?;
let app = NSApplication::sharedApplication(mtm);
let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject());
let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?;
let icc = screen.colorSpace()?.ICCProfileData()?.to_vec();
(!icc.is_empty()).then_some(icc)
}
#[cfg(test)]
mod tests {
#[test]
fn unknown_off_the_main_thread() {
// AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour
assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None);
}
}
+316
View File
@@ -0,0 +1,316 @@
//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails
//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in
//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up).
//!
//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then
//! eframe's glow) is *probed* before the window opens: a device is created with an error handler
//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first
//! that builds cleanly is handed to eframe as an existing device (so its error handler stays).
//! - **Everywhere:** a marker file names the renderer being started and is removed once the
//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that
//! renderer last; [`relaunch_after_failure`] starts that next launch right away.
//! - The renderer that worked is remembered (`<config>/window-renderer`, Windows only) and tried
//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one.
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting};
use eframe::wgpu;
use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer};
use std::path::PathBuf;
/// Environment variable counting automatic relaunches after a failed start (bounds the chain).
const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY";
/// Never more relaunches than renderers to try.
const MAX_RETRIES: u32 = 4;
/// The window's renderer for this start.
pub struct Chosen {
pub renderer: eframe::Renderer,
pub wgpu_options: WgpuConfiguration,
pub which: WindowRenderer,
/// For the user: the last start failed with another renderer, or one failed now.
pub notice: Option<String>,
}
/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`).
pub struct Files {
dir: Option<PathBuf>,
}
impl Files {
pub fn new(dir: Option<PathBuf>) -> Self {
Self { dir }
}
fn marker(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("window-init.marker"))
}
fn remembered(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("window-renderer"))
}
fn read(p: Option<PathBuf>) -> Option<WindowRenderer> {
std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse)
}
fn write(p: Option<PathBuf>, r: WindowRenderer) {
let Some(p) = p else { return };
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) {
log::warn!("window: could not write {}: {e}", p.display());
}
}
/// The renderer the last start died in (marker left behind), removing the marker.
pub fn take_failed(&self) -> Option<WindowRenderer> {
let m = self.marker()?;
let r = Self::read(Some(m.clone()));
let _ = std::fs::remove_file(m);
r
}
/// Before the window opens: record what is being started.
pub fn arm(&self, r: WindowRenderer) {
Self::write(self.marker(), r);
}
/// The window's renderer is up: clear the marker and (Windows) remember it.
pub fn started(&self, r: WindowRenderer) {
if let Some(m) = self.marker() {
let _ = std::fs::remove_file(m);
}
if cfg!(windows) {
Self::write(self.remembered(), r);
}
}
/// The marker is still there: the window never came up.
pub fn armed(&self) -> bool {
self.marker().is_some_and(|m| m.exists())
}
}
/// The renderer for this start: see the module docs.
pub fn choose(files: &Files) -> Chosen {
let forced =
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
let failed = files.take_failed();
let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None };
let compiled = wgpu::Instance::enabled_backend_features();
let os = std::env::consts::OS;
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
let mut problems = Vec::new();
let chosen = if cfg!(windows) {
pick_probed(&order, &mut problems)
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
Some((WindowRenderer::Glow, None))
} else {
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
};
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
if !problems.is_empty() {
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
}
let notice = notice.map(|n| {
format!(
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
(see docs/gpu-pipeline.md → Troubleshooting).",
which.name(),
app = "LightCraft",
var = "LIGHTCRAFT_GPU_BACKEND",
)
});
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
let mut wgpu_options = base_options();
let renderer = match (which, existing) {
(WindowRenderer::Glow, _) => eframe::Renderer::Glow,
(WindowRenderer::Wgpu(_), Some(e)) => {
wgpu_options.wgpu_setup = WgpuSetup::Existing(e);
eframe::Renderer::Wgpu
}
(WindowRenderer::Wgpu(b), None) => {
if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup {
n.instance_descriptor.backends = b;
}
eframe::Renderer::Wgpu
}
};
Chosen { renderer, wgpu_options, which, notice }
}
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
pub fn base_options() -> WgpuConfiguration {
let mut c = WgpuConfiguration::default();
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = backend::window_backends();
n.instance_descriptor.backend_options = backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags);
}
c
}
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
for &r in order {
match r {
WindowRenderer::Glow => return Some((r, None)),
WindowRenderer::Wgpu(b) => {
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
match probed {
Ok(e) => return Some((r, Some(e))),
Err(e) => {
log::error!("window: the {} renderer failed: {e}", r.name());
problems.push(format!("The {} renderer failed ({e}).", r.name()));
}
}
}
}
}
None
}
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
backend_options: backend::backend_options(),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
display: None,
});
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance),
..Default::default()
}))
.map_err(|e| format!("no adapter: {e}"))?;
let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() };
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base },
..Default::default()
}))
.map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?;
install_error_handler(&device);
check_pipeline(&device, |d| {
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
})?;
Ok(WgpuSetupExisting { instance, adapter, device, queue })
}
/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as
/// fatal by default"); this one logs.
pub fn install_error_handler(device: &wgpu::Device) {
device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}")));
}
/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is
/// returned instead of reaching the uncaptured-error handler.
pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> {
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
build(device);
let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())];
match errors.into_iter().flatten().next() {
Some(e) => Err(e.to_string()),
None => Ok(()),
}
}
/// The start failed (error or panic) before the window's renderer came up: start the app again,
/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to
/// disk, or the retries are used up).
pub fn relaunch_after_failure(files: &Files) -> bool {
if !files.armed() {
return false;
}
let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
if tries >= MAX_RETRIES {
return false;
}
let Ok(exe) = std::env::current_exe() else { return false };
log::warn!("window: the renderer failed to start; starting again with the next one");
std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.env(RETRY_VAR, (tries + 1).to_string())
.spawn()
.map_err(|e| log::error!("window: could not start again: {e}"))
.is_ok()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
/// The marker names the renderer being started; left behind, the next start reads it as the
/// failed one (and removes it); a good start removes it and remembers the renderer (Windows).
#[test]
fn marker_records_the_failed_renderer() {
let d = dir("marker");
let f = Files::new(Some(d.clone()));
let dx = WindowRenderer::Wgpu(Backends::DX12);
f.arm(dx);
assert!(f.armed());
assert_eq!(f.take_failed(), Some(dx));
assert!(!f.armed());
assert_eq!(f.take_failed(), None);
f.arm(WindowRenderer::Glow);
f.started(WindowRenderer::Glow);
assert!(!f.armed());
assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow));
// nothing written without a settings folder
let none = Files::new(None);
none.arm(dx);
assert!(!none.armed() && !relaunch_after_failure(&none));
let _ = std::fs::remove_dir_all(&d);
}
/// The crash report: a pipeline that fails validation must come back as an error, never as
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
#[test]
fn pipeline_errors_are_captured_not_panics() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: backend::window_backends(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
eprintln!("no GPU adapter: skipped");
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
eprintln!("no GPU device: skipped");
return;
};
install_error_handler(&device);
// a shader that does not validate, then a pipeline using it (like a failed FXC compile)
let r = check_pipeline(&device, |d| {
let m = d.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("broken"),
source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4<f32> { return undefined_thing; }".into()),
});
let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("egui_pipeline"),
layout: None,
vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] },
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
});
});
assert!(r.is_err(), "{r:?}");
// egui's own pipeline builds cleanly here
check_pipeline(&device, |d| {
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
})
.unwrap();
// outside a scope: logged by our handler, not a panic
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
}
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
lightcraft-develop = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-raw = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+2 -1
View File
@@ -49,6 +49,7 @@
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -73,7 +74,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// 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 = 6;
pub const VERSION: u32 = 7;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+14
View File
@@ -214,6 +214,13 @@ pub enum Op {
id: PhotoId,
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
},
/// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the
/// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping,
/// not an edit: the look does not change, only the As Shot readout.
SetAsShotWb {
id: PhotoId,
wb: Option<(f64, f64)>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
@@ -685,6 +692,13 @@ impl Catalog {
let p = self.photo_mut(id)?;
Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
}
Op::SetAsShotWb { id, wb } => {
if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {
return Err(CatalogError::Invalid("as-shot white balance is not a number".into()));
}
let p = self.photo_mut(id)?;
Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) }
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
return Err(CatalogError::Invalid("empty file name".into()));
+2 -1
View File
@@ -284,7 +284,8 @@ impl Op {
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
| Op::SetContent { id, .. }
| Op::SetEmbeddedLens { id, .. } => out(*id),
| Op::SetEmbeddedLens { id, .. }
| Op::SetAsShotWb { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
+28 -7
View File
@@ -7,9 +7,15 @@
//! 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.
//! it is released explicitly 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.
//! - Dropping the lock calls [`std::fs::File::unlock`] rather than relying on the handle closing.
//! On Unix an `flock` lock belongs to the open file description and lasts while *any* descriptor
//! on it is open; a process that another thread is spawning holds a copy of every descriptor
//! (`O_CLOEXEC` only closes them at `execve`), so closing ours would leave the library "already
//! open" for a moment and a re-open would be refused (issue #680). `LOCK_UN` through our
//! descriptor frees the lock whatever copies a child still holds.
//! - 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.
@@ -87,7 +93,7 @@ pub enum LockError {
pub struct LibraryLock {
dir: PathBuf,
/// `None` when the file system can't lock (the library is open unlocked).
_file: Option<File>,
file: Option<File>,
held: bool,
}
@@ -96,7 +102,7 @@ impl LibraryLock {
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 }
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,
@@ -116,7 +122,7 @@ impl LibraryLock {
{
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
}
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
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).
@@ -127,14 +133,29 @@ impl LibraryLock {
pub fn dir(&self) -> &Path {
&self.dir
}
/// Another descriptor on the locked file: what a process spawned by another thread holds
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
#[cfg(test)]
pub(crate) fn descriptor_copy(&self) -> Option<File> {
self.file.as_ref().and_then(|f| f.try_clone().ok())
}
}
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.
/// then release the lock explicitly. Closing the handle isn't enough on Unix: `flock` lasts
/// while any copy of the descriptor is open, and a process another thread is spawning holds a
/// copy of every descriptor until it execs (issue #680). If the unlock fails the handle still
/// closes right after, which releases the lock once every copy is gone, as before.
fn drop(&mut self) {
if self.held {
let _ = std::fs::remove_file(self.dir.join(OWNER));
if let Some(f) = &self.file
&& let Err(e) = f.unlock()
{
log::warn!("library {}: can't release {LOCK}: {e}", self.dir.display());
}
}
}
}
+71 -11
View File
@@ -307,14 +307,25 @@ pub struct Analysis {
pub best: bool,
}
/// Raw formats whose readers have vendor white-balance multipliers but no measured camera
/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means
/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's
/// `fileFormat` name, in any case. See [`Photo::relative_wb`].
/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting
/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same
/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
}
impl Photo {
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
@@ -355,13 +366,18 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
/// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor
/// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies'
/// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats.
pub fn fitted_look(&self) -> bool {
self.develops_raw() && relative_wb_format(&self.format)
}
/// White balance relative to the camera's as-shot look, as for rendered photographs: a
/// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either
/// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin.
pub fn relative_wb(&self) -> bool {
self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera)
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
/// in the photo's own rendering process.
@@ -377,11 +393,15 @@ impl Photo {
/// process a photo renders with is not an edit, so comparisons with its defaults (edited?
/// still as imported?) don't change when a newer process exists. A new photo has the latest.
pub fn camera_defaults(&self) -> DevelopSettings {
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let relative = self.relative_wb();
let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let mut d = match wb {
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
None => DevelopSettings::default(),
};
if self.develops_raw() && !relative {
d.wb.scale = lightcraft_develop::WbScale::Kelvin;
}
if self.embedded_lens.is_some() {
d.optics.lens_profile = true;
}
@@ -487,6 +507,7 @@ impl Album {
#[cfg(test)]
mod edited_tests {
use super::*;
use lightcraft_develop::WbScale;
#[test]
fn raw_with_import_settings_is_not_edited() {
@@ -547,10 +568,13 @@ mod edited_tests {
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = "Pentax K-1".into();
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.relative_wb(), relative, "{format}");
assert_eq!(p.fitted_look(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}");
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
let mut custom = (*p.develop).clone();
@@ -559,7 +583,43 @@ mod edited_tests {
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
assert!(!p.relative_wb() && !p.fitted_look());
}
}
/// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730):
/// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on.
#[test]
fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() {
for (camera, format, relative) in [
("SONY ILCE-7M3", "ARW", false),
("SONY ILCE-7RM4A", "ARW", false),
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
("SONY ILCE-7M2", "ARW", true),
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = camera.into();
p.as_shot_wb = Some((4986.0, -2.0));
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
// a photo from an older catalog: stored As Shot on the relative scale, still not an edit
p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0));
assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit");
// its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot
p.as_shot_wb = Some((6500.0, 0.0));
assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot);
assert!(!p.is_edited());
}
}
}
+1 -1
View File
@@ -363,7 +363,7 @@ pub fn rule_date(s: &str) -> Result<String, Issue> {
_ => false,
}
};
if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
return Err(Issue::NeedsDate);
}
let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::<u32>().ok());
+22 -1
View File
@@ -466,7 +466,7 @@ fn set_embedded_lens_is_undoable_and_journaled() {
photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
})
.unwrap();
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None };
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };
let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
let inv = c.apply(op).unwrap();
@@ -476,6 +476,27 @@ fn set_embedded_lens_is_undoable_and_journaled() {
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
}
/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the
/// journal's JSON) takes it back, and a value that is not a number is refused.
#[test]
fn set_as_shot_wb_is_undoable_journaled_and_checked() {
let mut c = Catalog::default();
let a = c.alloc_photo_id();
let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00");
p.as_shot_wb = Some((6500.0, 0.0));
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) };
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
let inv = c.apply(op).unwrap();
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0)));
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err());
assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err());
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
}
/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
/// with its problems; a good one has none; an old album operator is read as it was meant.
#[test]
+2 -1
View File
@@ -210,8 +210,9 @@ fn op_variants_are_versioned() {
Op::SetEmbeddedLens { .. } => 4,
Op::SetKeyword { .. } => 5,
Op::SetFolderRecord { .. } => 6,
Op::SetAsShotWb { .. } => 7,
}
}
let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None });
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
+80
View File
@@ -5,6 +5,17 @@ use std::path::PathBuf;
use crate::lock::{LOCK, OWNER};
use crate::*;
/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of
/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file
/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock
/// another test drops in that moment stays held by the copy, so acquiring it again could fail with
/// "in use".
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
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);
@@ -14,6 +25,7 @@ fn temp_dir(tag: &str) -> PathBuf {
#[test]
fn second_opener_is_refused_until_the_first_lets_go() {
let _serial = serial();
let dir = temp_dir("twice");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(first.held());
@@ -38,6 +50,7 @@ fn second_opener_is_refused_until_the_first_lets_go() {
/// 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 _serial = serial();
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();
@@ -52,6 +65,7 @@ fn leftover_lock_files_are_not_a_lock() {
/// 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 _serial = serial();
let dir = temp_dir("child");
let exe = std::env::current_exe().unwrap();
let mut child = std::process::Command::new(exe)
@@ -91,3 +105,69 @@ fn lock_holder_child() {
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
std::thread::sleep(std::time::Duration::from_secs(120));
}
/// Issue #680: a process spawned by any thread holds a copy of every descriptor until it execs
/// (close-on-exec only closes them then), and `flock` lasts while any copy of the descriptor is
/// open. Dropping the `LibraryLock` must still free the library, so it releases explicitly rather
/// than relying on its handle closing. A `try_clone` of the descriptor stands in for the child's
/// copy: it refers to the same open file description, which is exactly what the child gets.
#[cfg(unix)]
#[test]
fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
let dir = temp_dir("copy");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
let copy = first.descriptor_copy().unwrap();
drop(first);
let again = LibraryLock::acquire(&dir, "lightcraft-cli").expect("the library is free once its LibraryLock is dropped");
assert!(again.held());
drop((again, copy));
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
#[cfg(unix)]
#[test]
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let dir = temp_dir("spawn");
let stop = Arc::new(AtomicBool::new(false));
let spawner = {
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) =
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
}
}
spawned
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let mut cycles = 0usize;
let mut refused = Vec::new();
while cycles < 200_000 && std::time::Instant::now() < deadline {
match LibraryLock::acquire(&dir, "LightCraft") {
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
}
cycles += 1;
}
stop.store(true, Ordering::Relaxed);
let spawned = spawner.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(spawned > 0, "the spawning thread never got a child off the ground");
assert!(
refused.is_empty(),
"{} of {cycles} re-acquires refused while {spawned} children were spawned: {:?}",
refused.len(),
&refused[..refused.len().min(3)]
);
}
+6 -1
View File
@@ -32,11 +32,14 @@ jxl-oxide = { workspace = true, optional = true }
ravif = { workspace = true, optional = true }
rav1e = { workspace = true, optional = true }
avif-serialize = { workspace = true, optional = true }
# spawns threads of its own (std::thread), which wasm32 has not got
jixel = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
jxl = ["dep:jxl-oxide"]
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
jxl = ["dep:jxl-oxide", "dep:jixel"]
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
@@ -45,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
# JPEG XL tests read encoded samples without our colour conversion
jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+3 -1
View File
@@ -23,6 +23,7 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
encode_webp_lossless(&img, &meta)?;
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
@@ -51,7 +52,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
| GIF / BMP | yes (first GIF frame) | — | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
@@ -87,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
+64 -4
View File
@@ -1,6 +1,6 @@
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
//! embedded where the format allows.
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
use crate::{Error, Result};
use lightcraft_raster::Rgba8;
@@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> {
EncodeImage { width, height, channels, samples }
}
fn validate(&self) -> Result<()> {
pub(crate) fn validate(&self) -> Result<()> {
if !(1..=4).contains(&self.channels) {
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
}
@@ -291,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
Ok(cur.into_inner())
}
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
crate::psd::encode(img, meta)
}
/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container.
pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
@@ -498,6 +504,60 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
}
}
/// Encode an HDR PNG: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
/// (`width × height × 3`), as for [`encode_avif_pq`]. Written as 16-bit RGB with the PQ curve
/// (SDR white at [`HDR_REFERENCE_WHITE_NITS`]), described by a `cICP` chunk (PNG third edition:
/// BT.2020 primaries, PQ, RGB, full range) and a `cLLI` content light level chunk (the same
/// levels the HDR AVIF records). EXIF and XMP are embedded; an ICC profile is not (`cICP`
/// describes the colour, and readers prefer it). Readers without `cICP` support show the PQ
/// samples as if they were sRGB (flat, washed out): expected for a PQ PNG, there is no fallback.
pub fn encode_png_pq(width: u32, height: u32, rgb: &[f32], meta: &EncodeMeta) -> Result<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
return Err(Error::Encode("HDR PNG: sample buffer too short".into()));
}
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 luminance
let mut max_nits = 0f32;
let mut sum_nits = 0f64;
let mut bytes = Vec::with_capacity(n * 6);
for c in rgb.as_chunks::<3>().0.iter().take(n) {
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += (K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]) as f64;
for v in nits {
bytes.extend_from_slice(&((pq_encode(v) * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes());
}
}
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u32, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u32);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.exif_metadata = meta.exif.map(|b| b.to_vec().into());
info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| {
let ppm = (p as f64 / 0.0254).round() as u32;
png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter }
});
let mut out = Vec::new();
let e = |e: png::EncodingError| Error::Encode(e.to_string());
{
let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?;
enc.set_compression(png::Compression::Fast);
if let Some(xmp) = meta.xmp {
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
}
let mut wr = enc.write_header().map_err(e)?;
// BT.2020 primaries (9), PQ (16), RGB (0), full range (1)
wr.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1]).map_err(e)?;
// MaxCLL and MaxFALL in units of 0.0001 cd/m²
let clli = [(cll.0 * 10_000).to_be_bytes(), (cll.1 * 10_000).to_be_bytes()].concat();
wr.write_chunk(png::chunk::ChunkType(*b"cLLI"), &clli).map_err(e)?;
wr.write_image_data(&bytes).map_err(e)?;
wr.finish().map_err(e)?;
}
Ok(out)
}
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
+1 -1
View File
@@ -411,7 +411,7 @@ pub fn compute(
if n == 0 || sdr.len() < n || hdr.len() < n {
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
}
if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
}
let s = o.scale;
+13 -1
View File
@@ -1,5 +1,7 @@
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
@@ -24,11 +26,13 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let gray = header.metadata.grayscale();
let icc = img.original_icc().map(|v| v.to_vec());
let mut meta = Meta { orientation: Some(1), ..Default::default() };
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
if icc.is_none() {
img.request_color_encoding(EnumColourEncoding {
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
white_point: WhitePoint::D65,
primaries: Primaries::Bt2100,
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
tf: TransferFunction::Linear,
rendering_intent: RenderingIntent::Relative,
});
@@ -55,11 +59,19 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let keep = n_color + has_alpha as usize;
let src = fb.buf();
let mut v = Vec::with_capacity(fw * fh * keep);
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
for px in src.chunks_exact(ch) {
let start = v.len();
v.extend_from_slice(&px[..keep.min(ch)]);
if keep > ch {
v.push(1.0);
}
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
for (o, m) in rgb.iter_mut().zip(&to_2020) {
*o = m[0] * r + m[1] * g + m[2] * b;
}
}
}
let raw = Raw {
width: fw,
+188
View File
@@ -0,0 +1,188 @@
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
/// exports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JxlEffort {
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
Fast,
#[default]
Normal,
}
impl JxlEffort {
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"fast" => Self::Fast,
"normal" => Self::Normal,
_ => return None,
})
}
}
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum JxlColour {
#[default]
Srgb,
/// P3 primaries, D65, sRGB curve.
DisplayP3,
/// Rec. 2020 primaries with the BT.709 curve.
Rec2020,
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
/// samples are already PQ-encoded.
Rec2020Pq,
}
/// JPEG XL encoder settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct JxlOptions {
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
pub quality: u8,
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
pub lossless: bool,
pub effort: JxlEffort,
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
/// preview that refines to the exact image).
pub progressive: bool,
pub colour: JxlColour,
}
impl Default for JxlOptions {
fn default() -> Self {
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
}
}
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
{
let pixels = Pixels::from(img, o.colour)?;
// jixel asserts internally; a panic there is an export error, not a crash
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
}
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
{
let _ = (o, meta);
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
}
}
/// Interleaved RGB or RGBA samples for jixel.
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
enum Pixels {
U8 { data: Vec<u8>, alpha: bool },
U16 { data: Vec<u16>, alpha: bool },
}
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
impl Pixels {
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
let ch = img.channels as usize;
// keep alpha only when it says something
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
let px = s.chunks_exact(ch).take(n);
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
let out_ch = if alpha { 4 } else { 3 };
let mut v = Vec::with_capacity(n * out_ch);
for c in px {
match c {
[g] => v.extend_from_slice(&[*g, *g, *g]),
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
[] => {}
}
}
(v, alpha)
}
Ok(match img.samples {
Samples::U8(s) => {
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
Pixels::U8 { data, alpha }
}
Samples::U16(s) => {
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
Pixels::U16 { data, alpha }
}
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
let s: Vec<u16> = s
.chunks_exact(ch)
.take(n)
.flat_map(|c| match c {
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
[] => [0; 4],
})
.collect();
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
Pixels::U16 { data, alpha }
}
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
})
}
}
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
let colour = match o.colour {
JxlColour::Srgb => ColorEncoding::srgb(),
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
};
let mut c = EncodeConfig::default()
.with_quality(f32::from(o.quality.clamp(1, 100)))
.with_color_encoding(colour)
.with_lossless(o.lossless)
.with_progressive(o.progressive)
.with_speed(match o.effort {
JxlEffort::Fast => Speed::Fastest,
JxlEffort::Normal => Speed::Fast,
});
if o.colour == JxlColour::Rec2020Pq {
// PQ code values are absolute: 1.0 = 10 000 cd/m²
c = c.with_intensity_target(10_000.0);
}
if o.lossless
&& let Some(icc) = meta.icc
{
c = c.with_icc_profile(icc.to_vec());
}
if let Some(exif) = meta.exif {
c = c.with_exif(exif.to_vec());
}
if let Some(xmp) = meta.xmp {
c = c.with_xmp(xmp.as_bytes().to_vec());
}
let r = match px {
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
};
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
}
+6 -2
View File
@@ -9,7 +9,8 @@
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//!
//! No decoder panics on malformed input (property-tested).
@@ -27,8 +28,10 @@ mod jpeg;
pub mod jpeg_par;
#[cfg(feature = "jxl")]
mod jxl;
mod jxl_encode;
mod other;
mod png_codec;
pub mod proof;
mod psd;
mod sniff;
pub mod space;
@@ -37,8 +40,9 @@ mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
encode_png, encode_tiff, encode_webp_lossless,
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
};
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
+174
View File
@@ -0,0 +1,174 @@
//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix
//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020)
//! through the device and back, so the loupe shows what the print can hold.
//!
//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative
//! colorimetric: colours outside the device gamut come back mapped into it.
//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white
//! (the profile's media white point) replaces the screen's white, and the ink's black is the
//! darkest the device tables give (no black-point compensation). The white is applied as a
//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the
//! near-neutral whites of papers).
//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour.
//!
//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling);
//! the transforms are moxcms's. Own design otherwise.
use lightcraft_color::{D50, D65, REC2020, bradford};
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions};
use crate::display::MAX_PROFILE_BYTES;
/// Rendering intent of the forward (image → device) conversion.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ProofIntent {
#[default]
Perceptual,
Relative,
}
impl ProofIntent {
pub fn parse(s: &str) -> Option<ProofIntent> {
match s.to_ascii_lowercase().as_str() {
"perceptual" => Some(ProofIntent::Perceptual),
"relative" | "relativecolorimetric" => Some(ProofIntent::Relative),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
ProofIntent::Perceptual => "perceptual",
ProofIntent::Relative => "relative",
}
}
}
/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index
/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance
/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside).
#[derive(Clone, Debug, PartialEq)]
pub struct ProofTable {
pub n: usize,
pub data: Vec<[f32; 4]>,
pub description: Option<String>,
}
/// Grid size of [`ProofTable`]s.
pub const PROOF_GRID: usize = 33;
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
}
/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here
/// panics on a hostile profile.
pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result<ProofTable, String> {
if bytes.len() > MAX_PROFILE_BYTES {
return Err("the profile is too large".into());
}
let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
let (layout, ch) = match dev.color_space {
DataColorSpace::Rgb => (Layout::Rgb, 3),
DataColorSpace::Cmyk => (Layout::Rgba, 4),
DataColorSpace::Gray => (Layout::Gray, 1),
_ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()),
};
let lin = crate::icc::linear_rec2020_profile();
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
let fwd_i = match intent {
ProofIntent::Perceptual => RenderingIntent::Perceptual,
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
};
let rel = RenderingIntent::RelativeColorimetric;
let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?;
let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?;
let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?;
let n = PROOF_GRID;
let grid: Vec<f32> = (0..n * n * n)
.flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)))
.collect();
let round_trip = |there: &dyn moxcms::TransformExecutor<f32>| -> Option<Vec<f32>> {
let mut device = vec![0f32; n * n * n * ch];
there.transform(&grid, &mut device).ok()?;
let mut out = vec![0f32; n * n * n * 3];
back.transform(&device, &mut out).ok()?;
Some(out)
};
let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?;
let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?;
let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] };
let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0));
let data = (0..n * n * n)
.map(|i| {
let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0);
let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max);
[c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist]
})
.collect();
let description = crate::icc::parse(bytes).and_then(|i| i.description);
Ok(ProofTable { n, data, description })
}
/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point.
fn paper_white(p: &ColorProfile) -> [f32; 3] {
let Some(w) = p.media_white_point else { return [1.0; 3] };
let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz());
let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] };
// the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it
let d50 = to_xyz.apply([1.0; 3]);
let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]];
let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]);
if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::space::NamedSpace;
fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] {
t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]]
}
#[test]
fn srgb_proof_flags_wide_colours_and_keeps_grey() {
let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap();
let top = t.n - 1;
// Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged
assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0]));
let g = at(&t, [16, 16, 16]);
assert!(g[3] < 0.01, "{g:?}");
let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0);
assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}");
let w = at(&t, [top; 3]);
assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}");
}
#[test]
fn paper_white_dims_and_tints_white() {
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
// a warm, slightly dull paper
p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 });
let bytes = p.encode().unwrap();
let top = PROOF_GRID - 1;
let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap();
let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap();
let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3]));
assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}");
assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}");
}
#[test]
fn hostile_profiles_are_errors() {
assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err());
assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err());
let good = crate::icc::write_named(NamedSpace::DisplayP3);
for i in (0..good.len()).step_by(11) {
let mut b = good.clone();
b[i] ^= 0x5a;
let _ = proof_table(&b, ProofIntent::Perceptual, true);
}
}
}
+177
View File
@@ -1,8 +1,11 @@
//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description):
//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data;
//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative.
//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document
//! with no layers, so an image editor opens it as one background layer and saves back to it.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::encode::{EncodeImage, EncodeMeta, Samples};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
const F: Format = Format::Psd;
@@ -211,6 +214,129 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
/// The largest side a PSD (version 1) may have; larger documents need PSB.
pub const MAX_SIDE: u32 = 30_000;
/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF
/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits
/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB;
/// alpha is dropped (the render is opaque). Float samples are refused.
pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
let (w, h) = (img.width, img.height);
if w > MAX_SIDE || h > MAX_SIDE {
return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF")));
}
let ch = usize::from(img.channels);
let color = if ch <= 2 { 1 } else { 3 };
let (depth, bpc) = match img.samples {
Samples::U8(_) => (8u16, 1usize),
Samples::U16(_) => (16, 2),
Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())),
};
let (wu, hu) = (w as usize, h as usize);
let mut b = Vec::new();
b.extend_from_slice(b"8BPS");
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&(color as u16).to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&depth.to_be_bytes());
b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
let res = resource_section(meta);
b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes());
b.extend_from_slice(&res);
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none
// planar samples, one plane per colour channel (big-endian)
let plane = |c: usize| -> Vec<u8> {
let mut p = Vec::with_capacity(wu * hu * bpc);
match img.samples {
Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)),
Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())),
Samples::F32(_) => {}
}
p
};
if bpc == 1 {
b.extend_from_slice(&1u16.to_be_bytes());
let mut counts = Vec::with_capacity(color * hu * 2);
let mut data = Vec::new();
for c in 0..color {
for row in plane(c).chunks_exact(wu) {
let start = data.len();
packbits(row, &mut data);
let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?;
counts.extend_from_slice(&n.to_be_bytes());
}
}
b.extend_from_slice(&counts);
b.extend_from_slice(&data);
} else {
b.extend_from_slice(&0u16.to_be_bytes());
for c in 0..color {
b.extend_from_slice(&plane(c));
}
}
Ok(b)
}
/// The image resources section for `meta`.
fn resource_section(meta: &EncodeMeta) -> Vec<u8> {
let mut out = Vec::new();
let mut add = |id: u16, data: &[u8]| {
out.extend_from_slice(b"8BIM");
out.extend_from_slice(&id.to_be_bytes());
out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
if data.len() % 2 == 1 {
out.push(0);
}
};
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
// ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice
let fixed = (u32::from(ppi) << 16).to_be_bytes();
let one = 1u16.to_be_bytes();
add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat());
}
if let Some(icc) = meta.icc {
add(1039, icc);
}
if let Some(exif) = meta.exif {
add(1058, exif);
}
if let Some(xmp) = meta.xmp {
add(1060, xmp.as_bytes());
}
out
}
/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the
/// rest as literals of at most 128 bytes).
fn packbits(src: &[u8], out: &mut Vec<u8>) {
let mut rest = src;
while let Some(&b) = rest.first() {
// a run of 2 to 128 equal bytes
let run = rest.iter().take(128).take_while(|&&x| x == b).count();
if run >= 2 {
// 1 - run in 2..=128: -1..=-127, as a signed byte
out.push((1 - run as i16) as i8 as u8);
out.push(b);
rest = rest.get(run..).unwrap_or_default();
continue;
}
// a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row
let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count();
let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired };
let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..]));
out.push(lit.len().saturating_sub(1) as u8);
out.extend_from_slice(lit);
rest = tail;
}
}
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
/// depth, colour mode or compression, or merged image data that runs past the end of the file
@@ -434,4 +560,55 @@ mod tests {
let q = d.image.get(0, 0);
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
}
#[test]
fn packbits_round_trips() {
let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::<Vec<_>>()];
for row in rows {
let mut enc = Vec::new();
super::packbits(row, &mut enc);
let mut out = vec![0u8; row.len()];
unpackbits(&enc, &mut out).unwrap();
assert_eq!(out, row);
}
}
#[test]
fn flat_psd_round_trips_8_and_16_bit() {
let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb);
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x:xmpmeta/>"), ppi: Some(300), ..Default::default() };
// 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient
let px: Vec<u8> = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect();
let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap();
assert_eq!(super::header(&b).unwrap(), (5, 3, 1));
let d = decode(&b, &DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (5, 3));
assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept");
assert_eq!(d.xmp.as_deref(), Some("<x:xmpmeta/>"));
// 16-bit RGB, raw planes: the samples come back exactly
let px16: Vec<u16> = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect();
let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap();
assert_eq!(&b[22..24], &16u16.to_be_bytes());
let mut r = Rd { b: &b, p: 26 };
let cm = r.u32().unwrap() as usize;
r.take(cm).unwrap();
let res = r.u32().unwrap() as usize;
r.take(res).unwrap();
assert_eq!(r.u32().unwrap(), 0, "no layers");
assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw");
let red: Vec<u16> = (0..8).map(|_| r.u16().unwrap()).collect();
assert_eq!(red, px16.iter().step_by(3).copied().collect::<Vec<_>>());
assert!(decode(&b, &DecodeOptions::default()).is_ok());
// gray 16-bit
let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap();
assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode");
assert!(decode(&g, &DecodeOptions::default()).is_ok());
}
#[test]
fn flat_psd_refuses_what_it_cannot_hold() {
let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err();
assert!(e.to_string().contains("float"), "{e}");
let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err();
assert!(e.to_string().contains("TIFF"), "{e}");
}
}
+9
View File
@@ -66,6 +66,15 @@ fn bench_24mp_jpeg() {
let rgba = d.to_srgb8();
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
#[cfg(feature = "jxl")]
for (label, o) in [
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
("encode JPEG XL q90 effort normal", JxlOptions::default()),
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
] {
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
println!(" size {:.1} MB", b.len() as f64 / 1e6);
}
}
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
+180
View File
@@ -635,3 +635,183 @@ fn print_resolution_is_written() {
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
}
#[test]
fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() {
// linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x
let (w, h) = (64u32, 4u32);
let rgb: Vec<f32> = (0..w * h)
.flat_map(|i| {
let v = 0.01 * 1.11f32.powi((i % w) as i32);
[v, v * 0.5, v * 0.25]
})
.collect();
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"/>"#;
let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap();
let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap();
let cicp = r.info().coding_independent_code_points.expect("cICP");
assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ");
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let f = r.next_frame(&mut buf).unwrap();
assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h));
// every sample decodes back to its luminance
for x in [0usize, 20, 63] {
for c in 0..3 {
let o = (x * 3 + c) * 2;
let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0;
let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS;
assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e));
}
}
// cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m²
let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI");
let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS;
assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}");
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept");
assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept");
assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP");
}
#[test]
fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err());
assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err());
assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err());
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
}
/// Largest per-channel difference between two linear images.
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossless_exact_with_metadata() {
let img = gradient(97, 61);
let icc = write_named(NamedSpace::Srgb);
let exif = minimal_exif(1);
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
let o = JxlOptions { lossless: true, ..Default::default() };
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
assert_eq!(sniff(&b), Some(Format::Jxl));
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (97, 61));
assert!(!d.has_alpha, "opaque alpha is dropped");
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
assert_eq!(d.xmp.as_deref(), Some(xmp));
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
// 16-bit lossless keeps every code value
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.bit_depth, 16);
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
let want = decode(&png, DecodeOptions::default()).unwrap();
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
let img = smooth(160, 120);
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
for (q, b) in [(95, &hi), (40, &lo)] {
let d = decode(b, DecodeOptions::default()).unwrap();
let p = psnr8(&opaque, &d.to_srgb8());
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
}
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
for progressive in [false, true] {
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
}
}
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
// profile, decode to the same colours in the working space
let img = smooth(96, 64);
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
let icc = write_named(named);
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
let e = EncodeImage::rgba8(&opaque);
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
let a = decode(&lossy, DecodeOptions::default()).unwrap();
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
let b = decode(&exact, DecodeOptions::default()).unwrap();
let d = max_diff(&a.to_working(), &b.to_working());
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
}
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_alpha_and_gray() {
let mut img = gradient(32, 24);
img.data[5][3] = 0;
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert!(d.has_alpha);
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
.unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_hdr_pq() {
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
let (w, h) = (64usize, 32usize);
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
for lossless in [false, true] {
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
// the file's own samples: PQ code values for 203 and 812 cd/m²
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
let m = &img.image_header().metadata;
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
let r = img.render_frame(0).unwrap();
let fb = r.image_all_channels();
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
}
}
// float samples are HDR only
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
assert!(matches!(sdr, Err(Error::Encode(_))));
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_bad_input_is_an_error() {
let px = [0u8; 12];
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
// a 1×1 image and quality 1 still encode
let one = [10u8, 20, 30];
let b =
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
}
+2 -1
View File
@@ -149,7 +149,8 @@ impl RgbSpace {
}
}
const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in.
pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
+2
View File
@@ -12,6 +12,7 @@
//! blend with the plain demosaic that the Amount slider controls.
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
//! - [`remote`]: running tiles on another computer's graphics card over TCP (a [`run::TileRunner`] and its server).
//!
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
//! be finite, and a malformed file is an error, never a panic.
@@ -26,6 +27,7 @@ pub mod onnx;
mod onnx_proto;
pub mod reference;
pub mod remote;
#[cfg(feature = "runtime")]
pub mod runtime;
+567
View File
@@ -0,0 +1,567 @@
//! Remote denoise: run a model's tiles on another computer's graphics card over TCP. Meant for a LAN or a VPN such as
//! Tailscale or WireGuard: the connection itself is not encrypted.
//!
//! [`Remote`] is a [`TileRunner`] that sends each tile to a server; [`serve`] runs one (`lightcraft-cli
//! denoise-serve`). Both ends must run the same model, checked by its identity ([`ModelId`]) when they greet.
//!
//! Protocol (version 1). Each message is one JSON line (`\n`-terminated, ≤ 4 KiB), optionally followed by a payload of
//! `bytes` bytes: the tile's samples as little-endian half floats, byte-shuffled (every sample's low byte, then every
//! high byte) and deflated, which roughly halves what crosses a slow link.
//!
//! ```text
//! → {"v":1,"token":"…","op":"hello"}
//! ← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"}}
//! → {"v":1,"token":"…","op":"run","model":"<sha256>","tile":T,"bytes":N} + N bytes (4 planes of T × T)
//! ← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T)
//! ← {"ok":false,"error":"…"} (any failure)
//! ```
//!
//! A connection serves any number of requests. The server refuses a wrong token, another model than its own, tiles of
//! another size, oversized headers and payloads (also ones that inflate past their tile), and caps concurrent
//! connections; the client gives up on a server that keeps failing, so the computer's own runner does the rest.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use serde::Serialize;
use serde_json::{Value, json};
use crate::product::{from_f16, to_f16};
use crate::run::{Error, TileRunner};
pub const PROTOCOL: u64 = 1;
pub const DEFAULT_PORT: u16 = 7990;
const MAX_LINE: usize = 4096;
/// Largest tile side in cells (a 1024-cell tile is a 2048 × 2048 pixel answer).
pub const MAX_TILE: usize = 1024;
/// Shortest token a server accepts.
pub const MIN_TOKEN: usize = 16;
/// Which model a server runs: both ends must agree before tiles cross.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ModelId {
pub id: String,
pub version: String,
/// SHA-256 of the model file (hex).
pub sha256: String,
}
fn bad(m: impl Into<String>) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, m.into())
}
fn read_line(r: &mut impl BufRead) -> std::io::Result<Option<String>> {
let mut buf = Vec::new();
let n = r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf)?;
if n == 0 {
return Ok(None);
}
if buf.last() != Some(&b'\n') {
return Err(bad("message header too long or truncated"));
}
String::from_utf8(buf).map(Some).map_err(|_| bad("header is not UTF-8"))
}
/// Samples → half floats → byte-shuffled → deflated.
pub fn pack(data: &[f32]) -> Vec<u8> {
let n = data.len();
let mut b = vec![0u8; n * 2];
let (lo, hi) = b.split_at_mut(n);
for ((v, l), h) in data.iter().zip(lo.iter_mut()).zip(hi.iter_mut()) {
[*l, *h] = to_f16(*v).to_le_bytes();
}
miniz_oxide::deflate::compress_to_vec(&b, 1)
}
/// The inverse of [`pack`] for exactly `n` samples; refuses anything that inflates to more.
pub fn unpack(bytes: &[u8], n: usize) -> std::io::Result<Vec<f32>> {
let b = miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, n * 2).map_err(|_| bad("payload doesn't inflate to the tile size"))?;
if b.len() != n * 2 {
return Err(bad("payload size doesn't match the tile"));
}
let (lo, hi) = b.split_at(n);
Ok(lo.iter().zip(hi).map(|(l, h)| from_f16(u16::from_le_bytes([*l, *h]))).collect())
}
fn write_payload(w: &mut impl Write, mut header: Value, data: &[f32]) -> std::io::Result<()> {
let packed = pack(data);
header["bytes"] = json!(packed.len());
writeln!(w, "{header}")?;
w.write_all(&packed)
}
/// Read a `bytes`-byte payload holding `n` samples (`bytes` is capped by the tile, so a hostile header can't make us
/// allocate more than an uncompressed tile plus slack).
fn read_payload(r: &mut impl Read, bytes: usize, n: usize) -> std::io::Result<Vec<f32>> {
if bytes > n * 2 + 4096 {
return Err(bad("payload larger than its tile"));
}
let mut b = vec![0u8; bytes];
r.read_exact(&mut b)?;
unpack(&b, n)
}
fn field(v: &Value, k: &str) -> Option<usize> {
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
}
/// Constant-time comparison (the token must not leak through timing).
fn same(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut d = a.len() ^ b.len();
for i in 0..a.len().max(b.len()) {
d |= (a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)) as usize;
}
d == 0
}
/// What a server runs a tile with: four planes of `tile × tile` cells in, three planes of `2·tile × 2·tile` out.
pub type RunTile = Arc<dyn Fn(&[f32]) -> Result<Vec<f32>, Error> + Send + Sync>;
/// A server's settings.
#[derive(Clone, Debug)]
pub struct ServerOptions {
/// Shared secret every request must carry (at least [`MIN_TOKEN`] characters).
pub token: String,
pub max_connections: usize,
/// Idle and transfer limit per connection.
pub timeout: Duration,
}
impl Default for ServerOptions {
fn default() -> Self {
ServerOptions { token: String::new(), max_connections: 8, timeout: Duration::from_secs(120) }
}
}
/// Serve `model` (tiles of `tile` cells, run by `run` on `device`) until the listener fails. `log` gets one line per event.
pub fn serve(
listener: TcpListener,
model: ModelId,
tile: usize,
device: String,
run: RunTile,
opts: ServerOptions,
log: Arc<dyn Fn(&str) + Send + Sync>,
) -> Result<(), String> {
if opts.token.trim().chars().count() < MIN_TOKEN {
return Err(format!("the token must be at least {MIN_TOKEN} characters"));
}
if tile == 0 || tile > MAX_TILE {
return Err(format!("tile size {tile} is outside 1..={MAX_TILE}"));
}
let active = Arc::new(AtomicUsize::new(0));
let shared = Arc::new((model, device));
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into());
if active.load(Ordering::SeqCst) >= opts.max_connections.max(1) {
let mut s = stream;
let _ = writeln!(s, "{}", json!({"ok": false, "error": "server busy"}));
log(&format!("{peer}: refused (busy)"));
continue;
}
active.fetch_add(1, Ordering::SeqCst);
let (sh, r, o, l, n) = (shared.clone(), run.clone(), opts.clone(), log.clone(), active.clone());
let spawned = std::thread::Builder::new().name(format!("denoise {peer}")).spawn(move || {
if let Err(e) = handle(stream, &sh.0, tile, &sh.1, &*r, &o, &*l) {
l(&format!("{peer}: {e}"));
}
n.fetch_sub(1, Ordering::SeqCst);
});
if spawned.is_err() {
active.fetch_sub(1, Ordering::SeqCst);
log("could not start a connection thread");
}
}
Ok(())
}
fn handle(
stream: TcpStream,
model: &ModelId,
tile: usize,
device: &str,
run: &dyn Fn(&[f32]) -> Result<Vec<f32>, Error>,
opts: &ServerOptions,
log: &dyn Fn(&str),
) -> std::io::Result<()> {
stream.set_read_timeout(Some(opts.timeout))?;
stream.set_write_timeout(Some(opts.timeout))?;
stream.set_nodelay(true)?;
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
let mut r = BufReader::new(stream.try_clone()?);
let mut w = stream;
let mut tiles = 0usize;
let fail = |w: &mut TcpStream, msg: &str| writeln!(w, "{}", json!({"ok": false, "error": msg}));
while let Some(line) = read_line(&mut r)? {
let Ok(req) = serde_json::from_str::<Value>(&line) else {
fail(&mut w, "malformed request")?;
break;
};
if req.get("v").and_then(Value::as_u64) != Some(PROTOCOL) {
fail(&mut w, &format!("protocol version {PROTOCOL} only"))?;
break;
}
if !same(req.get("token").and_then(Value::as_str).unwrap_or(""), &opts.token) {
fail(&mut w, "bad token")?;
log(&format!("{peer}: bad token"));
break;
}
match req.get("op").and_then(Value::as_str) {
Some("hello") => writeln!(w, "{}", json!({"ok": true, "v": PROTOCOL, "device": device, "model": model, "tile": tile}))?,
Some("run") => {
let (Some(t), Some(bytes)) = (field(&req, "tile"), field(&req, "bytes")) else {
fail(&mut w, "missing tile size or payload size")?;
break;
};
if t != tile {
fail(&mut w, &format!("this server runs {tile}-cell tiles, not {t}"))?;
break;
}
// the payload is read before any refusal below, so the stream stays in step
let input = match read_payload(&mut r, bytes, 4 * t * t) {
Ok(d) => d,
Err(e) => {
fail(&mut w, &e.to_string())?;
break;
}
};
if req.get("model").and_then(Value::as_str) != Some(model.sha256.as_str()) {
fail(&mut w, &format!("model mismatch: this server runs {} {}", model.id, model.version))?;
continue;
}
match run(&input) {
Ok(out) if out.len() == 3 * 4 * t * t && out.iter().all(|v| v.is_finite()) => {
write_payload(&mut w, json!({"ok": true, "tile": t}), &out)?;
tiles += 1;
}
Ok(_) => fail(&mut w, "the model's answer has the wrong size or numbers that are not finite")?,
Err(e) => fail(&mut w, &e.to_string())?,
}
}
_ => {
fail(&mut w, "unknown op")?;
break;
}
}
w.flush()?;
}
if tiles > 0 {
log(&format!("{peer}: {tiles} tiles"));
}
Ok(())
}
/// What a server said in its greeting.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ServerInfo {
pub address: String,
pub device: String,
pub model: ModelId,
pub tile: usize,
}
/// Tiles the server may get wrong (an error, a broken connection) before it stops being used.
pub const MAX_FAILURES: usize = 3;
/// A server as a [`TileRunner`], for one model: keeps up to `slots` connections open and gives up on the server after
/// [`MAX_FAILURES`] failed tiles (the caller runs those tiles itself).
pub struct Remote {
address: String,
token: String,
model_sha256: String,
tile: usize,
slots: usize,
timeout: Duration,
busy: AtomicUsize,
failures: AtomicUsize,
last_error: Mutex<Option<String>>,
pool: Mutex<Vec<(BufReader<TcpStream>, TcpStream)>>,
}
impl Remote {
/// `address` as `host[:port]` (default port [`DEFAULT_PORT`]; `tcp://` and a trailing `/` are ignored).
pub fn new(address: &str, token: &str, model_sha256: &str, tile: usize) -> Remote {
let a = address.trim().trim_start_matches("tcp://").trim_end_matches('/');
let has_port = a.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && !h.ends_with(':') && p.parse::<u16>().is_ok()) && !a.ends_with(']');
let address = if has_port { a.to_string() } else { format!("{a}:{DEFAULT_PORT}") };
Remote {
address,
token: token.into(),
model_sha256: model_sha256.to_ascii_lowercase(),
tile,
slots: 4,
timeout: Duration::from_secs(60),
busy: AtomicUsize::new(0),
failures: AtomicUsize::new(0),
last_error: Mutex::new(None),
pool: Mutex::new(Vec::new()),
}
}
pub fn with_slots(mut self, n: usize) -> Remote {
self.slots = n.clamp(1, 16);
self
}
pub fn address(&self) -> &str {
&self.address
}
/// Tiles it can take at once.
pub fn slots(&self) -> usize {
self.slots
}
/// Still used (fewer than [`MAX_FAILURES`] failed tiles).
pub fn healthy(&self) -> bool {
self.failures.load(Ordering::Relaxed) < MAX_FAILURES
}
pub fn failures(&self) -> usize {
self.failures.load(Ordering::Relaxed)
}
/// Why the last tile failed.
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn err(&self, reason: impl std::fmt::Display) -> Error {
Error::Runtime(format!("remote {}: {reason}", self.address))
}
fn connect(&self) -> Result<(BufReader<TcpStream>, TcpStream), Error> {
let addr = self.address.to_socket_addrs().map_err(|e| self.err(e))?.next().ok_or_else(|| self.err("the address doesn't resolve"))?;
let s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| self.err(e))?;
s.set_read_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
s.set_write_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
s.set_nodelay(true).map_err(|e| self.err(e))?;
let r = BufReader::new(s.try_clone().map_err(|e| self.err(e))?);
Ok((r, s))
}
fn reply(&self, r: &mut BufReader<TcpStream>) -> Result<Value, Error> {
let line = read_line(r).map_err(|e| self.err(e))?.ok_or_else(|| self.err("the connection closed"))?;
let v: Value = serde_json::from_str(&line).map_err(|_| self.err("malformed reply"))?;
if v.get("ok").and_then(Value::as_bool) != Some(true) {
return Err(self.err(v.get("error").and_then(Value::as_str).unwrap_or("request failed")));
}
Ok(v)
}
/// Greet the server: its device and model. Fails when it runs another model or tile size than this one.
pub fn hello(&self) -> Result<ServerInfo, Error> {
let (mut r, mut s) = self.connect()?;
writeln!(s, "{}", json!({"v": PROTOCOL, "token": self.token, "op": "hello"})).map_err(|e| self.err(e))?;
let v = self.reply(&mut r)?;
let m = v.get("model").cloned().unwrap_or_default();
let text = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let info = ServerInfo {
address: self.address.clone(),
device: text(&v, "device"),
model: ModelId { id: text(&m, "id"), version: text(&m, "version"), sha256: text(&m, "sha256") },
tile: field(&v, "tile").unwrap_or(0),
};
if !info.model.sha256.eq_ignore_ascii_case(&self.model_sha256) {
return Err(self.err(format!("it runs another model ({} {})", info.model.id, info.model.version)));
}
if info.tile != self.tile {
return Err(self.err(format!("it runs {}-cell tiles, this model {}", info.tile, self.tile)));
}
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
Ok(info)
}
/// Run a tile if a slot is free right now; `None` when all are busy or the server was given up (the caller runs
/// the tile elsewhere). A failure counts towards giving up on the server.
pub fn try_run(&self, input: &[f32]) -> Option<Result<Vec<f32>, Error>> {
if !self.healthy() {
return None;
}
if self.busy.fetch_add(1, Ordering::SeqCst) >= self.slots {
self.busy.fetch_sub(1, Ordering::SeqCst);
return None;
}
let r = self.run_now(input);
self.busy.fetch_sub(1, Ordering::SeqCst);
if let Err(e) = &r {
self.failures.fetch_add(1, Ordering::Relaxed);
*self.last_error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.to_string());
}
Some(r)
}
fn run_now(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
let t = self.tile;
if input.len() != 4 * t * t {
return Err(Error::Input(format!("a tile of {} samples for {t}-cell tiles", input.len())));
}
let pooled = self.pool.lock().unwrap_or_else(PoisonError::into_inner).pop();
let (mut r, mut s) = match pooled {
Some(c) => c,
None => self.connect()?,
};
let req = json!({"v": PROTOCOL, "token": self.token, "op": "run", "model": self.model_sha256, "tile": t});
write_payload(&mut s, req, input).map_err(|e| self.err(e))?;
s.flush().map_err(|e| self.err(e))?;
let v = self.reply(&mut r)?;
if field(&v, "tile") != Some(t) {
return Err(self.err("the answer is for another tile size"));
}
let bytes = field(&v, "bytes").ok_or_else(|| self.err("an answer without its size"))?;
let out = read_payload(&mut r, bytes, 3 * 4 * t * t).map_err(|e| self.err(e))?;
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
// the other computer's numbers go straight into the render: NaN or infinity (an f16 can carry
// both) is a failed tile, which `Shared` runs here instead
if !out.iter().all(|v| v.is_finite()) {
return Err(self.err("the answer holds values that aren't finite"));
}
Ok(out)
}
}
/// Tiles on the server when one of its slots is free, else (and for any tile it fails) on `local`: the server adds
/// speed to this computer's runner instead of replacing it.
pub struct Shared<'a> {
pub remote: &'a Remote,
pub local: &'a dyn TileRunner,
}
impl TileRunner for Shared<'_> {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
match self.remote.try_run(input) {
Some(Ok(out)) => Ok(out),
Some(Err(_)) | None => self.local.run(input),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "0123456789abcdef-test";
const T: usize = 8;
/// A stand-in model: each output pixel is its cell's first plane, times `k`.
fn model(k: f32) -> RunTile {
Arc::new(move |input: &[f32]| {
let t = T;
let mut out = vec![0f32; 3 * 4 * t * t];
for c in 0..3 {
for y in 0..2 * t {
for x in 0..2 * t {
out[(c * 2 * t + y) * 2 * t + x] = input[(y / 2) * t + x / 2] * k;
}
}
}
Ok(out)
})
}
fn id(sha: &str) -> ModelId {
ModelId { id: "test".into(), version: "1".into(), sha256: sha.into() }
}
fn start(sha: &str, run: RunTile) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap().to_string();
let opts = ServerOptions { token: TOKEN.into(), ..Default::default() };
let m = id(sha);
std::thread::spawn(move || serve(l, m, T, "CPU (test)".into(), run, opts, Arc::new(|_| {})));
addr
}
struct Local(RunTile, AtomicUsize);
impl TileRunner for Local {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
self.1.fetch_add(1, Ordering::SeqCst);
(self.0)(input)
}
}
fn tile_input(seed: f32) -> Vec<f32> {
(0..4 * T * T).map(|i| (i as f32 * 0.37 + seed).sin() * 0.5 + 0.5).collect()
}
#[test]
fn remote_tiles_match_local_ones_within_half_float_rounding() {
let addr = start("aa11", model(2.0));
let remote = Remote::new(&addr, TOKEN, "AA11", T);
let info = remote.hello().unwrap();
assert_eq!((info.device.as_str(), info.tile), ("CPU (test)", T));
let input = tile_input(0.3);
let got = remote.try_run(&input).unwrap().unwrap();
let want = model(2.0)(&input).unwrap();
assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 2e-3));
}
#[test]
fn shared_runner_falls_back_and_gives_up_on_a_failing_server() {
let local = Local(model(2.0), AtomicUsize::new(0));
// nobody listening: every tile runs locally, and after a few the server is given up
let dead = Remote::new("127.0.0.1:1", TOKEN, "aa11", T);
let shared = Shared { remote: &dead, local: &local };
for i in 0..6 {
assert!(shared.run(&tile_input(i as f32)).is_ok());
}
assert_eq!(local.1.load(Ordering::SeqCst), 6);
assert!(!dead.healthy() && dead.last_error().is_some());
// a server with another model is refused at the greeting and per tile
let addr = start("bb22", model(2.0));
let other = Remote::new(&addr, TOKEN, "aa11", T);
assert!(other.hello().unwrap_err().to_string().contains("another model"));
assert!(other.try_run(&tile_input(0.0)).unwrap().unwrap_err().to_string().contains("model mismatch"));
// a wrong token
let addr = start("aa11", model(2.0));
assert!(Remote::new(&addr, "wrong-token-0000000", "aa11", T).hello().unwrap_err().to_string().contains("bad token"));
// all slots busy: the tile runs locally without waiting
let busy = Remote::new(&addr, TOKEN, "aa11", T).with_slots(1);
busy.busy.store(1, Ordering::SeqCst);
assert!(busy.try_run(&tile_input(0.0)).is_none());
}
#[test]
fn hostile_requests_are_refused_and_the_server_keeps_working() {
let addr = start("aa11", model(1.0));
let talk = |bytes: &[u8]| -> String {
let mut s = TcpStream::connect(&addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(bytes).unwrap();
let mut line = String::new();
let _ = BufReader::new(s).read_line(&mut line);
line
};
assert!(talk(b"not json\n").contains("malformed"));
let v2 = format!("{}\n", json!({"v": 2, "token": TOKEN, "op": "hello"}));
assert!(talk(v2.as_bytes()).contains("protocol"));
let big = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": 4096, "bytes": 10}));
assert!(talk(big.as_bytes()).contains("8-cell"));
// a payload that inflates past its tile
let bomb = miniz_oxide::deflate::compress_to_vec(&vec![0u8; 1 << 20], 9);
let hdr = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": T, "bytes": bomb.len()}));
assert!(talk(&[hdr.as_bytes(), &bomb].concat()).contains("false"));
assert!(Remote::new(&addr, TOKEN, "aa11", T).hello().is_ok());
assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "abcd"));
// a short token is refused when the server starts
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let opts = ServerOptions { token: "short".into(), ..Default::default() };
assert!(serve(l, id("aa11"), T, "x".into(), model(1.0), opts, Arc::new(|_| {})).is_err());
}
#[test]
fn packing_round_trips_and_addresses_are_normalised() {
let v: Vec<f32> = (0..4096).map(|i| 0.5 + ((i * 7919 % 101) as f32 - 50.0) * 0.002).collect();
let back = unpack(&pack(&v), v.len()).unwrap();
assert!(v.iter().zip(&back).all(|(a, b)| (a - b).abs() < 1e-3));
assert!(unpack(&pack(&v), v.len() + 1).is_err());
assert_eq!(Remote::new("tcp://100.1.2.3/", TOKEN, "a", T).address(), "100.1.2.3:7990");
assert_eq!(Remote::new("box:8000", TOKEN, "a", T).address(), "box:8000");
assert_eq!(Remote::new("[::1]", TOKEN, "a", T).address(), "[::1]:7990");
}
}
+53 -2
View File
@@ -38,11 +38,21 @@ impl DevelopSettings {
serde_json::from_value(v.clone())
}
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
/// Kelvin settings.
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
DevelopSettings::from_json(&v)
let mut out = DevelopSettings::from_json(&v)?;
if let Some(wb) = partial.get("wb").and_then(Value::as_object)
&& (wb.contains_key("temp") || wb.contains_key("tint"))
&& !wb.contains_key("scale")
{
out.wb.scale = WbScale::Legacy;
}
Ok(out)
}
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
@@ -167,6 +177,47 @@ mod tests {
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
#[test]
fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() {
// settings from before the scale existed: no field, `Legacy`, and written back without it
let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}});
let s = DevelopSettings::from_json(&old).unwrap();
assert_eq!(s.wb.scale, WbScale::Legacy);
assert_eq!(s.to_json()["wb"], old["wb"]);
assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy);
assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none());
// written on the Kelvin scale: said so, and read back
let mut k = s.clone();
k.wb.scale = WbScale::Kelvin;
assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin"));
assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k);
assert_ne!(k.hash64(), s.hash64());
// an unknown scale from a newer build is an error, not a panic
assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err());
}
#[test]
fn a_partial_white_balance_without_a_scale_is_legacy() {
let mut base = DevelopSettings::default();
base.wb.scale = WbScale::Kelvin;
// an old preset's white balance lands on Kelvin settings: it keeps its relative meaning
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0);
assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0));
let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Legacy);
// one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
// the plain merge (`develop.merge`) follows the same rule
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy);
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin);
assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin);
}
#[test]
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
let s = DevelopSettings::default();
+1
View File
@@ -187,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let base = s.to_json();
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
for c in crate::controls::CONTROLS {
if let Some(v) = crate::controls::get(&out, c.id) {
+78 -7
View File
@@ -81,16 +81,20 @@ impl ProcessVersion {
pub enum Process {
/// The rendering of LightCraft before process versions existed (2026-10).
V1,
/// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base
/// is taken of a fine median of log luminance (issue #632).
V2,
}
impl Process {
/// Every process, oldest first.
pub const ALL: [Process; 1] = [Process::V1];
pub const LATEST: Process = Process::V1;
pub const ALL: [Process; 2] = [Process::V1, Process::V2];
pub const LATEST: Process = Process::V2;
pub const fn version(self) -> ProcessVersion {
match self {
Process::V1 => ProcessVersion(1),
Process::V2 => ProcessVersion(2),
}
}
}
@@ -242,7 +246,13 @@ impl WbMode {
WbMode::Custom => "Custom",
}
}
/// Preset temperature/tint for raw files (K, tint).
/// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 /
/// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for
/// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction
/// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta
/// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same
/// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws
/// without matrices) `develop.wb` scales them around 6500 K = as shot.
pub fn preset(self) -> Option<(f64, f64)> {
match self {
WbMode::Daylight => Some((5500.0, 10.0)),
@@ -256,9 +266,35 @@ impl WbMode {
}
}
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on.
///
/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin;
/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0
/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them
/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's
/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the
/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WbScale {
/// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot)
/// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written.
#[default]
Legacy,
/// Absolute Kelvin and tint, as Lightroom writes them for raws.
Kelvin,
}
impl WbScale {
fn is_legacy(&self) -> bool {
*self == WbScale::Legacy
}
}
/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the
/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is
/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a
/// relative −100..100 scale for those, like Lightroom).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WhiteBalance {
@@ -266,11 +302,17 @@ pub struct WhiteBalance {
pub temp: f64,
/// Correction direction: negative adds green, positive adds magenta.
pub tint: f64,
/// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings
/// from before it existed read and write unchanged.
#[serde(skip_serializing_if = "WbScale::is_legacy")]
pub scale: WbScale,
}
impl Default for WhiteBalance {
fn default() -> Self {
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
// (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the
// scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`)
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy }
}
}
@@ -656,6 +698,33 @@ impl Default for Optics {
pub struct EmbeddedLens {
pub warp: Option<EmbeddedWarp>,
pub vignette: Option<EmbeddedVignette>,
/// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps
/// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode.
pub stages: [Option<EmbeddedWarpStage>; WARP_STAGES],
}
/// Most warp stages an [`EmbeddedLens`] chain keeps.
pub const WARP_STAGES: usize = 3;
/// One DNG `WarpRectilinear2` (or a `WarpRectilinear` in a chain with one): per plane (R, G, B)
/// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]`. For an output point at offset
/// `d = (p − center) / radius` with `r = |d|` and `f = Σ krₚ·clamp(r, min, max)ᵖ`, the source point is
/// `center + radius · (d·f + tangential(d))`, or `d / f` for the radial part when `reciprocal`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedWarpStage {
pub planes: [[f64; 19]; 3],
/// Optical centre, normalized to the oriented image (0..1).
pub center: Point,
/// Normalisation radius as a fraction of the oriented image's long edge.
pub radius: f64,
pub reciprocal: bool,
}
impl EmbeddedLens {
/// Whether a `WarpRectilinear2` chain is set.
pub fn has_stages(&self) -> bool {
self.stages.iter().any(Option::is_some)
}
}
/// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at
@@ -963,6 +1032,8 @@ pub enum SpotMode {
Remove,
Heal,
Clone,
/// Content-aware: the spot is rebuilt from patches of its surroundings (no single source).
Fill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+4
View File
@@ -24,9 +24,11 @@ lightcraft-raw = { workspace = true }
lightcraft-denoise-core = { workspace = true }
lightcraft-denoise = { workspace = true, optional = true }
lightcraft-meta = { workspace = true }
lightcraft-lensdb = { workspace = true }
lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
lightcraft-geocode = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -49,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
# HDR JPEG XL tests read the file's colour encoding
jxl-oxide = { workspace = true }
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
lightcraft-heif = { workspace = true, features = ["testdata"] }
libc = { workspace = true }
+64 -17
View File
@@ -73,7 +73,7 @@ impl Unit {
pub struct TaskInfo {
pub id: u64,
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
/// `faces`, `denoise`, `findMissing`.
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
@@ -100,6 +100,12 @@ struct Entry {
detail: Mutex<String>,
cancel: Option<Arc<AtomicBool>>,
cancellable: AtomicBool,
/// Held while a cancel is decided ([`Activity::cancel`], [`Activity::cancel_all`]) and while
/// cancelling is switched ([`TaskGuard::set_cancellable`], [`TaskHandle::set_cancellable`]): a
/// step that turns it off and then reads the cancel flag either sees a cancel that was
/// accepted, or makes the cancel fail. Without it a cancel could pass the check, the step
/// start, and the flag be set after the step's last look at it: accepted but ignored.
gate: Mutex<()>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
@@ -111,6 +117,23 @@ fn capped(text: &str) -> String {
}
impl Entry {
fn set_cancellable(&self, yes: bool) {
let _gate = lock(&self.gate);
self.cancellable.store(yes, Ordering::Relaxed);
}
/// Set the cancel flag if the task can be cancelled now (see [`Entry::gate`]).
fn try_cancel(&self) -> bool {
let _gate = lock(&self.gate);
match &self.cancel {
Some(flag) if self.is_cancellable() => {
flag.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
fn is_cancellable(&self) -> bool {
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
}
@@ -165,6 +188,7 @@ impl Activity {
detail: Mutex::new(String::new()),
cancellable: AtomicBool::new(cancel.is_some()),
cancel,
gate: Mutex::new(()),
});
lock(&self.inner.tasks).push(entry.clone());
TaskGuard { activity: self.clone(), entry }
@@ -180,26 +204,13 @@ impl Activity {
pub fn cancel(&self, id: u64) -> Result<(), String> {
let tasks = lock(&self.inner.tasks);
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
match (&entry.cancel, entry.is_cancellable()) {
(Some(flag), true) => {
flag.store(true, Ordering::Relaxed);
Ok(())
}
_ => Err(format!("{} can't be cancelled", entry.label)),
}
if entry.try_cancel() { Ok(()) } else { Err(format!("{} can't be cancelled", entry.label)) }
}
/// Ask every cancellable task to stop; returns how many were asked.
pub fn cancel_all(&self) -> usize {
let tasks = lock(&self.inner.tasks);
let mut n = 0;
for e in tasks.iter().filter(|e| e.is_cancellable()) {
if let Some(flag) = &e.cancel {
flag.store(true, Ordering::Relaxed);
n += 1;
}
}
n
tasks.iter().filter(|e| e.try_cancel()).count()
}
/// Tasks that quitting would cut short: cancellable ones not already stopping.
@@ -235,7 +246,7 @@ impl TaskGuard {
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
pub fn set_cancellable(&self, yes: bool) {
self.entry.cancellable.store(yes, Ordering::Relaxed);
self.entry.set_cancellable(yes);
}
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
@@ -283,6 +294,13 @@ impl TaskHandle {
pub fn is_cancelled(&self) -> bool {
self.entry.is_cancelled()
}
/// [`TaskGuard::set_cancellable`] from the worker, at the moment its step starts (an export
/// handing its files to Apple Photos, which can't be stopped once asked). Read the cancel flag
/// after turning it off: a cancel accepted before is seen then, any later one is refused.
pub fn set_cancellable(&self, yes: bool) {
self.entry.set_cancellable(yes);
}
}
#[cfg(test)]
@@ -380,6 +398,35 @@ mod tests {
assert_eq!(a.list().len(), 1);
}
/// Codex review of #643: a worker that turns cancelling off and then reads the cancel flag
/// (an export handing its files to Apple Photos) must either see a cancel that was accepted or
/// make it fail, never let one be accepted and then ignored. Here the test holds the task's gate
/// as a cancel does while it decides: the worker's switch waits for it, and then sees the cancel.
#[test]
fn a_step_that_turns_cancelling_off_sees_or_refuses_every_cancel() {
let a = Activity::default();
let flag = Arc::new(AtomicBool::new(false));
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
let handle = g.handle();
let gate = lock(&g.entry.gate); // a cancel deciding
let (tx, rx) = std::sync::mpsc::channel();
let (h, f) = (handle.clone(), flag.clone());
let worker = std::thread::spawn(move || {
h.set_cancellable(false);
tx.send(f.load(Ordering::Relaxed)).unwrap();
});
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "the switch waits for the cancel");
flag.store(true, Ordering::Relaxed); // the cancel goes through...
drop(gate);
assert!(rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(), "...and the step sees it");
worker.join().unwrap();
// once it is off, cancels are refused
let g2 = a.start("export", "Exporting", Cancel::Yes);
g2.handle().set_cancellable(false);
assert!(a.cancel(g2.id()).is_err());
assert_eq!(a.cancel_all(), 0, "{:?}", a.list());
}
#[test]
fn handle_updates_from_another_thread() {
let a = Activity::default();
File diff suppressed because it is too large. Load diff
+531 -35
View File
@@ -22,8 +22,10 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa
/// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies.
///
/// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO)
/// and the ILCE-7CR profile (#528, #583, #568, #616).
pub const LOOK_VERSION: u32 = 2;
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
/// states, for every make (#778).
/// 4: a tone curve from black-and-white camera JPEGs (#577).
pub const LOOK_VERSION: u32 = 4;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -63,16 +65,20 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to
/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`);
/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is
/// accepted renders as without them; when used they are written into `raw.color`. Returns the as-shot white, the
/// camera transform at it and the fitted look.
/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
/// as-shot white, the camera transform at it and the fitted look.
pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option<CameraLook>) {
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t))
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t))
}
/// [`starting_colour`] with the JPEG fit passed in (tests pin the order with it).
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
/// JPEG's tone fit passed in (tests pin the order with them).
fn starting_colour_with(
raw: &mut RawImage,
fit: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option<CameraLook>, bool),
monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
) -> (Xy, CameraTransform, Option<CameraLook>) {
let start = |raw: &RawImage| {
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
@@ -82,30 +88,47 @@ fn starting_colour_with(
if !t.matrix_is_fallback {
return (xy, t, None);
}
if let Some(look) = fit(raw, &t) {
let (look, black_and_white) = fit(raw, &t);
if let Some(look) = look {
return (xy, t, Some(look));
}
let Some(camera) = raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) else {
return (xy, t, None);
let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) {
Some(camera) => {
camera.fill(&mut raw.color);
if lightcraft_pipeline::profiling() {
eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model);
}
start(raw)
}
None => (xy, t),
};
camera.fill(&mut raw.color);
if lightcraft_pipeline::profiling() {
eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model);
}
let (xy, t) = start(raw);
(xy, t, None)
// only a black-and-white JPEG: a colour one that gave no look pays nothing more
let look = if black_and_white { monochrome(raw, &t) } else { None };
(xy, t, look)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
return None;
return (None, false);
}
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?;
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) };
let black_and_white = monochrome(&reference);
// A camera profile pooled from many photos knows colours this photo shows too little of;
// try its colour first and fit tone/chroma per photo (picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let look = fit_look(&sensor, &reference, &clipped, colour)?;
let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) {
Some(look) => look,
// A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve
// (`monochrome_look` does it without a profile).
None if colour.is_some() && black_and_white => {
let unclipped = without_clipped(&sensor, &clipped);
let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) };
look
}
None => return (None, black_and_white),
};
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
@@ -120,7 +143,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
profile.is_some()
);
}
Some(look)
(Some(look), black_and_white)
}
/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in
@@ -761,6 +784,38 @@ fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
if variance > 0.0 { covariance / variance } else { 0.0 }
}
/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without
/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order,
/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related.
fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
let ranks = |values: Vec<f64>| {
let mut order: Vec<usize> = (0..values.len()).collect();
order.sort_by(|a, b| values[*a].total_cmp(&values[*b]));
let mut ranks = vec![0.0; values.len()];
let mut start = 0;
while start < order.len() {
let mut end = start + 1;
while end < order.len() && values[order[end]] == values[order[start]] {
end += 1;
}
let mean = (start + end - 1) as f64 / 2.0;
for &i in &order[start..end] {
ranks[i] = mean;
}
start = end;
}
ranks
};
let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect());
let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect());
let mean = (pairs.len() as f64 - 1.0) / 2.0;
let (covariance, scene_variance, jpeg_variance) = scene
.iter()
.zip(&jpeg)
.fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean)));
if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 }
}
/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of
/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs
/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered
@@ -828,11 +883,128 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
(samples > 0 && better).then_some(look)
}
/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey.
const GREY_SPREAD: f32 = 0.002;
/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon
/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within
/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one.
fn monochrome(reference: &Rgb32f) -> bool {
let coloured = reference
.data
.iter()
.filter(|p| {
let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
!p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD
})
.count();
reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len()
}
/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera
/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo
/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
if !file_local_look(raw.format) {
return None;
}
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?;
if !monochrome(&reference) {
return None;
}
let unclipped = without_clipped(&sensor, &clipped);
let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?;
// Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}",
raw.format,
look.tone,
if transform.matrix_is_fallback { "fallback" } else { "spectral" }
);
}
Some(look)
}
/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's
/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of
/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture
/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values.
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit))
}
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
// every pair counts: the JPEG has no colour to require
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?;
let related = rank_correlation_ties(&pairs);
if related < PARTIAL_MIN_RANK_CORRELATION {
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture");
}
return None;
}
let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None));
let correction = hue_sat.as_ref().and_then(HueSat::new);
let coloured = |x: [f64; 3]| {
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() };
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
for (x, target) in pairs.iter().step_by(3) {
let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target));
before += (fallback - target).powi(2);
after += (toned - target).powi(2);
before_encoded += (encoded(fallback) - encoded(target)).powi(2);
after_encoded += (encoded(toned) - encoded(target)).powi(2);
samples += 1;
}
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
(before / samples.max(1) as f64).sqrt(),
(after / samples.max(1) as f64).sqrt(),
if edge_limit.is_some() { ", away from edges" } else { "" }
);
}
let better =
after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT;
(samples > 0 && better).then_some(look)
}
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
fn to_prophoto() -> Mat3 {
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
}
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
/// its colour; in between the two are blended, so noisy shadows show no step.
const MIN_LUMINANCE_KEPT: f32 = 0.25;
const NO_LUMINANCE_KEPT: f32 = 0.15;
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
fn restore_weight(share: f32) -> f32 {
if !share.is_finite() {
return 0.0;
}
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
pub(crate) struct HueSat<'a> {
@@ -852,7 +1024,19 @@ impl<'a> HueSat<'a> {
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
// restoring the luminance divides by the table's: near black, where noise makes some channels
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
return rgb;
}
let w = restore_weight(after / before);
if w >= 1.0 {
return out.map(|v| v * before / after);
}
if w <= 0.0 {
return rgb;
}
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
}
}
@@ -1439,6 +1623,61 @@ mod tests {
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
}
#[test]
fn hue_sat_never_turns_near_black_noise_white() {
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
};
let mut worst = 0.0f32;
for _ in 0..200_000 {
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
if luminance_2020(rgb) <= 0.0 {
continue;
}
let out = hue_sat.apply(rgb);
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
}
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
}
#[test]
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
assert_eq!(restore_weight(0.0), 0.0);
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
assert_eq!(restore_weight(1.0), 1.0);
assert_eq!(restore_weight(f32::NAN), 0.0);
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
let mut last = 0.0f32;
for i in 0..=400 {
let w = restore_weight(i as f32 * 0.001);
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
last = w;
}
}
#[test]
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
assert!(after >= MIN_LUMINANCE_KEPT * before);
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
}
}
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
RawImage {
@@ -1469,7 +1708,11 @@ mod tests {
let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]);
let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone());
let (_, t, look) = starting_colour_with(&mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices"));
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| panic!("no JPEG fit for a raw with its own matrices"),
|_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"),
);
assert_eq!(raw.color, color);
assert!(!t.matrix_is_fallback && look.is_none());
}
@@ -1483,10 +1726,14 @@ mod tests {
assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}");
let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default());
let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt());
let (xy, t, look) = starting_colour_with(&mut raw, |raw, t| {
assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices");
Some(fitted.clone())
});
let (xy, t, look) = starting_colour_with(
&mut raw,
|raw, t| {
assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices");
(Some(fitted.clone()), false)
},
|_, _| panic!("no black-and-white JPEG fit after an accepted fit"),
);
assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}");
assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out");
assert!(t.matrix_is_fallback, "{model}");
@@ -1508,7 +1755,7 @@ mod tests {
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (xy, t, look) = starting_colour_with(&mut raw, |_, _| None);
let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"));
assert!(look.is_none());
assert_eq!(raw.color.illuminant, [17, 21]);
assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some));
@@ -1519,6 +1766,43 @@ mod tests {
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}");
}
/// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the
/// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise
/// (issue #577). The black-and-white fit sees that transform.
#[test]
fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() {
let toned = CameraLook {
matrix: Mat3::IDENTITY,
tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone,
hue_sat: None,
};
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (xy, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, true),
|raw, t| {
assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in");
Some(toned.clone())
},
);
assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top");
assert!(!t.matrix_is_fallback);
assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw));
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, true),
|_, t| {
assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour");
Some(toned.clone())
},
);
assert!(look.is_some() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
}
/// Step 5: a camera outside the table whose fit fails keeps the neutral fallback.
#[test]
fn a_camera_outside_the_table_keeps_the_fallback() {
@@ -1559,7 +1843,31 @@ mod tests {
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
assert_eq!(info.relative_wb, !own, "{name}");
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
// spectral matrices, whichever look it got (issue #730)
assert_eq!(info.relative_wb, !own && !covered, "{name}");
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
if covered && !own {
assert!(
(2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0,
"{name}: {info:?}"
);
}
// the same as the catalog decides from the probe (format and camera)
let probe = crate::files::probe_bytes(name, &bytes).unwrap();
let mut p = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(1),
lightcraft_catalog::Source::File { path: name.into() },
name,
&probe.format,
1,
1,
"",
);
p.kind = probe.kind;
p.meta = probe.meta.clone();
assert_eq!(p.relative_wb(), info.relative_wb, "{name}");
assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
}
}
@@ -1635,7 +1943,7 @@ mod tests {
assert_eq!(raw.format, RawFormat::Cr3);
assert_eq!(raw.info().developed_size(), (6000, 4000));
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform);
let look = fit_preview(&raw, &bytes, &transform).0;
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
@@ -1668,7 +1976,7 @@ mod tests {
let raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR"));
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform).expect("fit accepted");
let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted");
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
for (row, want) in look.matrix.0.iter().zip(expected.0) {
for (got, want) in row.iter().zip(want) {
@@ -1914,7 +2222,7 @@ mod tests {
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform).expect("a camera look");
let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look");
// the look fitted to the JPEG, as before this change
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap();
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
@@ -1988,15 +2296,24 @@ mod tests {
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
// (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those
// matrices; the X-T2 and X-T20 have none and stay relative)
for (name, accepted, kelvin) in
[("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)]
{
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let header = crate::files::probe_bytes(name, &bytes).unwrap();
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}");
assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}");
if kelvin {
assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp);
} else {
assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
}
}
}
@@ -2314,4 +2631,183 @@ mod tests {
reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]);
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG");
}
/// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's
/// luminance, grey in every pixel.
fn black_and_white_scene() -> (Rgb32f, Rgb32f) {
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
for (i, p) in sensor.data.iter_mut().enumerate() {
let ev = 0.01 + (i % 53) as f32 * 0.009;
*p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)];
}
let mut reference = sensor.clone();
reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]);
(sensor, reference)
}
#[test]
fn black_and_white_jpegs_are_recognised() {
let (sensor, mut reference) = black_and_white_scene();
assert!(monochrome(&reference));
assert!(!monochrome(&sensor), "a colour JPEG");
// a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour
let n = reference.data.len();
reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05);
assert!(monochrome(&reference));
reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05);
assert!(!monochrome(&reference));
// a sepia-toned monochrome
let (_, mut toned) = black_and_white_scene();
toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]);
assert!(!monochrome(&toned));
assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell");
// the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts
let (_, mut faint) = black_and_white_scene();
faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]);
assert!(monochrome(&faint));
faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]);
assert!(!monochrome(&faint));
// pixels that aren't numbers can't vouch for grey
let (_, mut broken) = black_and_white_scene();
broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]);
assert!(!monochrome(&broken));
}
/// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's
/// brightness like the camera, with the colour left as it was and the chroma curve neutral.
#[test]
fn a_black_and_white_jpeg_gives_its_tone_curve() {
let (sensor, reference) = black_and_white_scene();
let none = vec![false; sensor.data.len()];
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit");
let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve");
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was");
assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma());
let n = sensor.data.len() as f64;
let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::<f64>() / n;
let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::<f64>() / n;
let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0));
assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}");
assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker");
// with a camera profile's colour, the look carries it
let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]);
let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour");
assert_eq!(look.matrix, matrix);
}
/// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries
/// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's.
#[test]
fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() {
// saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped)
let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]];
let mut sensor = Rgb32f::new(96, 64);
for (i, p) in sensor.data.iter_mut().enumerate() {
let ev = 0.02 + (i % 47) as f32 * 0.008;
*p = hues[i % 6].map(|c| c * ev);
}
// a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let mut reference = sensor.clone();
reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]);
let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks");
let own = fit_tone_only(&sensor, &reference, None);
assert!(own.is_some(), "the photo's own colour passes");
// and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices)
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, true),
|_, t| {
assert!(!t.matrix_is_fallback, "on the spectral matrices");
own.clone()
},
);
assert!(look.is_some() && !t.matrix_is_fallback);
}
/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG,
/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the
/// same file with a tinted JPEG gets nothing from this step.
#[test]
fn monochrome_look_reads_the_files_black_and_white_jpeg() {
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect};
let (w, h) = (192usize, 128usize);
let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
let raw = RawImage {
format: RawFormat::Arw,
width: w,
height: h,
cpp: 3,
data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()),
cfa: None,
bits: 16,
black: BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::from_exif(1),
color: ColorData::default(),
wb_multipliers: Some([1.0; 3]),
linearized: true,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
};
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
// the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG
let file = |tint: [f32; 3]| {
let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| {
let v = 1.0 - (-3.0 * level(i % w, i / w)).exp();
tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8)
})
.collect();
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
// II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it
let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec();
for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] {
tiff.extend(tag.to_le_bytes());
tiff.extend(4u16.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(value.to_le_bytes());
}
tiff.extend(0u32.to_le_bytes());
assert_eq!(tiff.len(), 38);
tiff.extend(jpeg);
tiff
};
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG");
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour");
let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let mid = curve.apply(0.25);
assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}");
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white");
}
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
/// already renders, gives no look.
#[test]
fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() {
let (sensor, mut reference) = black_and_white_scene();
let neutral = ToneMap::new(0.0, 0.0, 0.0);
reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]);
assert!(monochrome(&reference));
assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve");
for (i, p) in reference.data.iter_mut().enumerate() {
*p = [0.05 + (i % 29) as f32 * 0.02; 3];
}
assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture");
// a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties
// ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey)
let mut sorted = sensor.clone();
sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b)));
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
}
}
+146 -2
View File
@@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
v.sort();
v
};
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
// looking at a folder never brings its deleted photos back (importing the files does)
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
let report = import_with(s, &files, &opts)?;
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
@@ -105,7 +107,7 @@ impl Session {
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()));
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()));
if dry_run {
return plan;
}
@@ -195,6 +197,24 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"folder.create",
"New Folder on Disk",
[],
None,
"{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
always,
create_folder
),
cmd!(
"photo.moveToFolder",
"Move to Folder",
[],
None,
"{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}",
always,
move_to_folder
),
cmd!(
"library.browse",
"Browse Folder",
@@ -249,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
}
/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the
/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites.
pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
match (from.is_empty(), to.is_empty()) {
(false, false) => rename_folder_on_disk(from, to),
(false, true) => remove_empty_folder(from),
(true, false) => {
if Path::new(to).exists() {
return Err(format!("{to} already exists"));
}
std::fs::create_dir(to).map_err(|e| format!("{to}: {e}"))
}
(true, true) => Ok(()),
}
}
/// Remove an empty folder; one with anything in it (hidden files too) is refused.
fn remove_empty_folder(path: &str) -> std::result::Result<(), String> {
let p = Path::new(path);
if !p.is_dir() {
return Err(format!("{path} is not a folder"));
}
let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?;
if entries.next().is_some() {
return Err(format!("{path} is not empty"));
}
std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}"))
}
/// A folder path parameter without trailing separators (`None` when missing or blank).
fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
}
/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "folder.create";
let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
.ok_or_else(|| bad(C, "give a folder name (no slashes)"))?;
if !Path::new(parent).is_dir() {
return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
}
let path = Path::new(parent).join(name).to_string_lossy().to_string();
if Path::new(&path).exists() {
return Err(bad(C, format!("{path} already exists: choose another name")));
}
folder_on_disk("", &path).map_err(|e| bad(C, e))?;
let undo = crate::FolderMove { from: path.clone(), to: String::new() };
if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
let _ = remove_empty_folder(&path);
return Err(e);
}
Ok(json!({"path": path}))
}
/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink
/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name
/// already taken in `folder` refuses the whole move.
fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.moveToFolder";
let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
let dir = Path::new(folder);
if !dir.is_dir() {
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
}
let ids = s.targets(p);
if ids.is_empty() {
return Err(bad(C, "no photos to move: select some or give ids"));
}
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut clashes: Vec<String> = Vec::new();
for id in ids {
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
if ph.local {
return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name)));
}
let lightcraft_catalog::Source::File { path } = &ph.source else {
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
};
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
continue; // a virtual copy of a photo already moving
}
let src = Path::new(path);
if !src.is_file() {
return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)")));
}
let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) };
if src.parent() == Some(dir) {
continue; // already there
}
let to = dir.join(file);
let to_s = to.to_string_lossy().to_string();
if to.exists() || !taken.insert(to_s.to_lowercase()) {
clashes.push(file.to_string_lossy().to_string());
continue;
}
let name = ph.file_name.clone();
plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) });
}
if !clashes.is_empty() {
let s_ = if clashes.len() == 1 { "" } else { "s" };
return Err(bad(
C,
format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")),
));
}
let n = plans.len();
if n == 0 {
return Ok(json!({"moved": 0, "folder": folder}));
}
let plural = |k: usize| if k == 1 { "" } else { "s" };
let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
Ok(json!({"moved": n, "folder": folder}))
}
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
// a library folder chosen at or below it follows too, as a source or as a filter
@@ -285,6 +426,9 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
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());
}
// Repairs retain their exact old paths, including those on surviving history entries.
// Complete them before the directory moves; an unavailable repair leaves it in place.
s.repair_sidecars_with(&crate::rename::RealFs).map_err(|e| e.to_string())?;
rename_folder_on_disk(from, to)?;
let mut ops: Vec<Op> = s
.catalog
+64 -19
View File
@@ -120,33 +120,67 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// 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.
/// TIFF (or PSD, or 8-bit) 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 mut ids = s.targets(p);
ids.dedup();
if ids.is_empty() {
return Err(bad(C, "no active photo"));
}
if ids.len() > MAX_EDIT_COPIES {
return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len())));
}
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 format = match super::str_param(p, "format").unwrap_or("tiff") {
"tiff" | "tif" => crate::export::ExportFormat::Tiff,
"psd" => crate::export::ExportFormat::Psd,
f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))),
};
let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) {
8 => 8,
16 => 16,
d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))),
};
let opts = crate::export::ExportOptions {
format: crate::export::ExportFormat::Tiff,
bit_depth: Some(16),
format,
bit_depth: Some(bit_depth),
color_space: space,
naming: "{name}-Edit".into(),
conflict: crate::export::Conflict::Unique,
..Default::default()
};
let mut copies = Vec::new();
for id in ids {
copies.push(edit_copy(s, p, id, &opts)?);
}
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
s.selection = crate::Selection { active: new.first().copied(), ids: new };
let first = copies.first().cloned().unwrap_or(Value::Null);
Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies}))
}
/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo).
const MAX_EDIT_COPIES: usize = 50;
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
const C: &str = "photo.editExternal";
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
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 mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
// durable: the TIFF becomes a library photo the external editor then changes
@@ -155,13 +189,13 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
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"))?;
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let ext = format!(".{}", opts.format.extension());
let out = written.into_iter().find(|w| w.ends_with(&ext)).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}))
}
@@ -229,6 +263,14 @@ pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog:
ops
}
/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the
/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices
/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730).
pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?;
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// 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> {
@@ -248,14 +290,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
reloaded.push(id);
}
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
@@ -342,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"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)",
"{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)",
has_active,
edit_external
),
@@ -381,6 +425,7 @@ mod tests {
lightcraft_develop::EmbeddedLens {
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
vignette: None,
stages: Default::default(),
}
}
fn old_rw2() -> lightcraft_catalog::Photo {
+1 -1
View File
@@ -653,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?, remote?: {address, state: pending | unavailable | used | idle | slower | given up, device?, remoteMs?, localMs?, reason?, failures?, lastError?}}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
]
+72 -27
View File
@@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
if o.swaps_axes() { (h, w) } else { (w, h) }
}
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
/// canvas outside the photo (issue #742).
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
if touched {
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
}
}
const CROP_ANGLE: &str = "crop.angle";
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
v?.as_array()?.iter().map(Value::as_f64).collect()
@@ -146,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Develop Values",
[],
None,
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
has_active,
|s, p| {
let mut vals: Vec<(String, f64)> = Vec::new();
@@ -167,10 +178,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
let before = d.crop.geometry;
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
lightcraft_pipeline::local::normalize_wb(d, info);
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
}
for (k, v) in &vals {
controls::set(d, k, *v);
@@ -178,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
d.wb.mode = WbMode::Custom;
}
}
refit_crop_angle(angle, before, d, dims);
};
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
@@ -187,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|(id, d)| {
let mut d = (*d).clone();
let info = s.source_info(id);
apply(&mut d, &info);
apply(&mut d, &info, crop_dims(s, id));
s.develop_op(id, d, &label)
})
.collect();
@@ -196,8 +213,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
let id = active(s, "develop.set")?;
let info = s.source_info(id);
let dims = crop_dims(s, id);
edit(s, "develop.set", &label, |d| {
apply(d, &info);
apply(d, &info, dims);
Ok(())
})
}
@@ -206,9 +224,12 @@ pub fn specs() -> Vec<CommandSpec> {
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
let delta = f64_req(p, "delta", "develop.adjust")?;
let info = s.source_info(active(s, "develop.adjust")?);
let id = active(s, "develop.adjust")?;
let (info, dims) = (s.source_info(id), crop_dims(s, id));
edit(s, "develop.adjust", spec.label, |d| {
let before = d.crop.geometry;
if c == "wb.temp" || c == "wb.tint" {
lightcraft_pipeline::local::normalize_wb(d, &info);
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
@@ -217,6 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
let v = controls::get(d, &c).unwrap_or(spec.default);
controls::set(d, &c, v + delta);
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
Ok(())
})
}),
@@ -239,11 +261,22 @@ pub fn specs() -> Vec<CommandSpec> {
let fresh = match look {
Some(l) => (*l).clone(),
None => {
// the photo's built-in defaults (raws: capture sharpening and colour noise
// reduction, embedded lens corrections), as at import, with the white balance
// of its current as-shot reference
let info = s.source_info(id);
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
..Default::default()
}
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
let mut fresh = DevelopSettings {
wb: lightcraft_develop::WhiteBalance {
mode: WbMode::AsShot,
temp: info.as_shot_temp,
tint: info.as_shot_tint,
..Default::default()
},
..defaults
};
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
fresh
}
};
// a fresh start renders with the current process (a section or slider reset doesn't change it)
@@ -302,8 +335,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
let dims = crop_dims(s, active(s, "develop.resetControl")?);
edit(s, "develop.resetControl", spec.label, |d| {
let before = d.crop.geometry;
controls::set(d, &c, spec.default);
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
Ok(())
})
}),
@@ -377,7 +413,7 @@ pub fn specs() -> Vec<CommandSpec> {
m => m
.preset()
.map(|(t, ti)| {
// presets are relative to daylight for rendered files
// presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
})
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
@@ -387,6 +423,7 @@ pub fn specs() -> Vec<CommandSpec> {
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
// mode is kept for the UI's WB dropdown.
edit(s, "develop.wb", "White Balance", |d| {
lightcraft_pipeline::local::normalize_wb(d, &info);
d.wb.mode = mode;
d.wb.temp = t.clamp(2000.0, 50000.0);
d.wb.tint = tint.clamp(-150.0, 150.0);
@@ -416,6 +453,7 @@ pub fn specs() -> Vec<CommandSpec> {
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
edit(s, "develop.wbPick", "White Balance", |d| {
lightcraft_pipeline::local::normalize_wb(d, &info);
d.wb.mode = WbMode::Custom;
d.wb.temp = t;
d.wb.tint = tint;
@@ -489,10 +527,12 @@ pub fn specs() -> Vec<CommandSpec> {
}
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
}
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
}
d.crop.geometry = d.crop.geometry.constrained(w, h);
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
d.crop.geometry = match (p.get("rect"), angle) {
// a new angle alone turns the crop as Straighten does (issue #742)
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
};
Ok(())
})
}),
@@ -501,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Drag Crop Handle",
[],
None,
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)",
has_active,
|s, p| {
let id = active(s, "crop.drag")?;
@@ -526,11 +566,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
};
let keep_ratio = bool_or(p, "keepRatio", false);
edit(s, "crop.drag", "Crop", |d| {
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
// orientation is reinterpreted per image, so the frame itself is the truth.
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
// `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock.
let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0);
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
Ok(())
})
@@ -548,11 +590,8 @@ pub fn specs() -> Vec<CommandSpec> {
let (w, h) = crop_dims(s, id);
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
edit(s, "crop.straighten", "Straighten", |d| {
let r = d.crop.geometry.rect_px(w, h);
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
// keep the current crop size if it still fits, else shrink
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
// keep the current crop if it still fits, else the largest of its aspect that does
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
Ok(())
})
}
@@ -847,13 +886,19 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
if ctl == "wb.temp" || ctl == "wb.tint" {
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
let info = s.source_info(id);
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
if nd.wb.mode == WbMode::AsShot {
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
}
}
let before = nd.crop.geometry;
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
if ctl == "wb.temp" || ctl == "wb.tint" {
nd.wb.mode = WbMode::Custom;
}
+93 -2
View File
@@ -78,8 +78,76 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(list(s))
}
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
#[cfg(not(target_arch = "wasm32"))]
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
match &s.apple_photos {
Some(_) => Ok(()),
None => Err(crate::apple_photos::unavailable().into()),
}
}
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
/// worker thread (the default in the desktop app, which answers control requests on its UI
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
#[cfg(not(target_arch = "wasm32"))]
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.addToPhotos";
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
}
let paths: Vec<String> = match p.get("paths") {
Some(Value::Array(a)) => a
.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<_>>()
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
};
let album = match p.get("album") {
None | Some(Value::Null) => "",
Some(Value::String(a)) => a.as_str(),
Some(_) => return Err(bad(ID, "`album` must be a string")),
};
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
// in the middle of the import
let wait = match p.get("wait") {
None | Some(Value::Null) => !s.apple_photos_background,
// the desktop app answers on its window's thread, which never waits for Photos
Some(Value::Bool(true)) if s.apple_photos_background => {
return Err(bad(
ID,
"`wait: true` isn't available in the desktop app: the import runs in the background; its outcome comes from export.photosImports {job}",
));
}
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
};
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait, Some(&s.activity)).map_err(|e| bad(ID, e))?;
match job.outcome() {
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
_ => Ok(job.json()),
}
}
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
#[cfg(not(target_arch = "wasm32"))]
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.photosImports";
match p.get("job") {
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
Some(v) => {
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
}
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
#[allow(unused_mut)]
let mut v = vec![
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
always, |s, p| {
@@ -123,5 +191,28 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": path}))
}
),
]
];
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
// replay must not import again.
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
query "export.addToPhotos",
"Add to Apple Photos",
[],
None,
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app, where wait: true is refused (its control requests are answered on the UI thread, which never waits for Photos). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
photos_enabled,
add_to_photos
));
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
query "export.photosImports",
"Apple Photos Imports",
[],
None,
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
always,
photos_imports
));
v
}
+11 -1
View File
@@ -464,6 +464,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
if st.embedder.as_deref() == Some(id) {
// another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen
st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id);
// on with nothing to run is not a state (issue #678)
st.enabled &= st.embedder.is_some();
write_settings(&dir, &st)?;
}
Ok(json!({"removed": id, "embedder": st.embedder}))
@@ -491,7 +493,15 @@ fn enable(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.enable";
let dir = models_dir(s, C)?;
let mut st = read_settings(&dir);
st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
let on = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
// on means something only with a model to run (issue #678): a toggle that is on with nothing
// chosen would sit there doing nothing, with no word about why
let usable =
st.embedder.as_deref().is_some_and(|id| installed_models(&dir).iter().any(|i| i.manifest.id == id && i.manifest.role == Role::Embedder));
if on && !usable {
return Err(bad(C, "choose a recognition model first (Settings ▸ Faces ▸ Use)"));
}
st.enabled = on;
write_settings(&dir, &st)?;
// the background scan notices at once, not within the second it trusts its last look
#[cfg(not(target_arch = "wasm32"))]
+472
View File
@@ -0,0 +1,472 @@
//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the
//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user
//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder;
//! after that every lookup is local and no position ever leaves the machine.
//!
//! Sources: GeoNames dump readme (prose); own design.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::SystemTime;
use serde_json::{Value, json};
use lightcraft_catalog::{Op, PhotoId};
use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon};
use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param};
use crate::activity::Cancel;
use crate::{Result, Session};
/// Where the place names live: `dataset` (a folder) or `<settings>/geonames`.
fn dataset_dir(p: &Value) -> Option<PathBuf> {
str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames")))
}
/// How a place-names download ended.
#[derive(Clone, Debug, PartialEq)]
pub enum DownloadOutcome {
/// Done: this many places.
Done(usize),
/// Failed, in words the user can act on.
Failed(String),
Cancelled,
}
#[derive(Default)]
struct DownloadShared {
running: bool,
last: Option<DownloadOutcome>,
/// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]).
unreported: Option<DownloadOutcome>,
}
/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state.
#[derive(Default)]
pub struct PlaceNames {
shared: Arc<Mutex<DownloadShared>>,
/// (cities file, its size, its modification time, the parsed names).
cache: Option<(PathBuf, u64, Option<SystemTime>, Arc<GeoNames>)>,
}
impl PlaceNames {
fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> {
self.shared.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A download that ended since the last call (the UI shows it once: an error stays readable as a toast).
pub fn take_outcome(&self) -> Option<DownloadOutcome> {
self.lock().unreported.take()
}
pub fn downloading(&self) -> bool {
self.lock().running
}
/// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes.
fn load(&mut self, dir: &Path) -> std::result::Result<Arc<GeoNames>, lightcraft_geocode::GeocodeError> {
let file = dir.join(GEONAMES_FILES[0]);
let meta = std::fs::metadata(&file).ok();
let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok()));
if let Some((f, l, t, g)) = &self.cache
&& meta.is_some()
&& *f == file
&& *l == len
&& *t == mtime
{
return Ok(g.clone());
}
let g = Arc::new(GeoNames::load(dir)?);
self.cache = Some((file, len, mtime, g.clone()));
Ok(g)
}
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
let dir = dataset_dir(p);
let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len());
let sh = s.place_names.lock();
let (last, error) = match &sh.last {
None => (Value::Null, Value::Null),
Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null),
Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)),
Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null),
};
Ok(json!({
"downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error,
"attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE,
}))
}
/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`.
fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) {
for (field, value) in [
(&mut meta.location, &place.sublocation),
(&mut meta.city, &place.city),
(&mut meta.state, &place.state),
(&mut meta.country, &place.country),
] {
if !value.is_empty() && (overwrite || field.trim().is_empty()) {
*field = value.clone();
}
}
}
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.reverseGeocode";
let overwrite = bool_or(p, "overwrite", false);
let ids = s.targets(p);
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
.filter_map(|id| {
let (la, lo) = s.catalog.photo(*id)?.meta.gps?;
let at = LatLon::new(la, lo);
at.is_valid().then_some((*id, at))
})
.collect();
if todo.is_empty() {
return Err(bad(C, "none of these photos has a GPS position"));
}
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?;
// a large selection shows in the activity stack and can be stopped
let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes));
let mut ops = Vec::new();
let mut found = Vec::new();
for (n, (id, at)) in todo.iter().enumerate() {
if let Some(t) = &task {
if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {
return Err(bad(C, "cancelled"));
}
t.progress(n as u64, todo.len() as u64);
}
let Some(place) = names.reverse(*at) else { continue };
let Some(ph) = s.catalog.photo(*id) else { continue };
let mut meta = ph.meta.clone();
fill(&mut meta, &place, overwrite);
if meta != ph.meta {
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
}
found.push(json!({"id": id.0, "place": place}));
}
drop(task);
let changed = ops.len();
if !bool_or(p, "dryRun", false) && changed > 0 {
s.commit("Reverse Geocode", Op::Batch { ops })?;
}
Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION}))
}
#[cfg(not(target_arch = "wasm32"))]
mod download {
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
use super::*;
use crate::activity::Unit;
const MAX_ZIP: u64 = 64 * 1024 * 1024;
const MAX_TEXT: usize = 256 * 1024 * 1024;
fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> {
let _ = std::fs::remove_file(dir.join(name));
let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP };
lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| {
task.progress(pr.done, pr.total.max(pr.done))
})
.map_err(|e| e.to_string())
}
/// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete.
pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<usize, String> {
let staging = dir.join(".download");
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?;
get(GEONAMES_ZIP, base, &staging, cancel, task)?;
let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?;
let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?;
let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len();
if places == 0 {
return Err("the downloaded place list is empty".into());
}
let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP));
let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}"));
for name in &GEONAMES_FILES[1..] {
// optional: without them states stay blank and countries are ISO codes
match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) {
Ok(b) => write(name, &b)?,
Err(e) => log::warn!("place names: {name}: {e}"),
}
let _ = std::fs::remove_file(staging.join(name));
}
write(GEONAMES_FILES[0], &cities)?;
let _ = std::fs::remove_dir(&staging);
Ok(places)
}
/// A download base: https, or plain http to this machine's loopback address only (a test
/// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/`
/// is refused.
pub(super) fn base_allowed(base: &str) -> bool {
base.starts_with("https://")
|| base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/'))
}
/// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable).
pub(super) fn start(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.downloadPlaceNames";
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string();
if !base_allowed(&base) {
return Err(bad(C, "`base` must be an https:// address"));
}
let shared = s.place_names.shared.clone();
{
let mut sh = s.place_names.lock();
if sh.running {
return Err(bad(C, "the place names are already downloading"));
}
sh.running = true;
}
let cancel = Arc::new(AtomicBool::new(false));
let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone()));
task.set_unit(Unit::Bytes);
task.detail("GeoNames");
let handle = task.handle();
let thread_dir = dir.clone();
let thread_shared = shared.clone();
let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {
// the activity row goes when the thread ends, however it ends
let _task = task;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));
let outcome = match outcome {
Ok(Ok(n)) => DownloadOutcome::Done(n),
_ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,
Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")),
Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()),
};
if let DownloadOutcome::Failed(e) = &outcome {
log::warn!("{e}");
}
let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);
sh.running = false;
sh.last = Some(outcome.clone());
sh.unreported = Some(outcome);
});
if let Err(e) = spawned {
shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;
return Err(bad(C, format!("could not start the download: {e}")));
}
Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION}))
}
}
pub fn specs() -> Vec<CommandSpec> {
#[allow(unused_mut)]
let mut v = vec![
cmd!(
"metadata.reverseGeocode",
"Fill Location from GPS",
["Photo"],
None,
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
has_selection,
reverse
),
cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status),
];
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
"metadata.downloadPlaceNames",
"Download Place Names (GeoNames)",
["Photo"],
None,
"{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent",
always,
download::start
));
v
}
#[cfg(test)]
mod tests {
use super::*;
const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\
2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n";
fn dataset(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap();
std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap();
std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap();
dir
}
fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) {
let mut meta = s.catalog.photo(id).unwrap().meta.clone();
meta.gps = gps;
meta.city = city.into();
meta.state.clear();
meta.country.clear();
meta.location.clear();
s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();
}
#[test]
fn fills_empty_fields_keeps_typed_ones_and_undoes() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
let dir = dataset("fill");
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster");
set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), "");
set_gps(&mut s, ids[3], Some((0.0, 0.0)), "");
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap();
assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}");
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France"));
let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone();
assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom"));
assert!(s.activity.list().is_empty(), "the activity row ends with the command");
// one undo step
s.undo_step().unwrap();
assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty());
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster");
// overwrite replaces typed text
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap();
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explains_missing_data_and_gps() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
set_gps(&mut s, ids[0], None, "");
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err();
assert!(e.to_string().contains("GPS"), "{e}");
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id()));
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err();
assert!(e.to_string().contains("download"), "{e}");
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap();
assert_eq!(st["downloaded"], false);
}
#[test]
fn the_gazetteer_is_cached_until_its_file_changes() {
let dir = dataset("cache");
let mut names = PlaceNames::default();
let a = names.load(&dir).unwrap();
assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read");
// a different size invalidates it (a new download)
std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap();
let b = names.load(&dir).unwrap();
assert!(!Arc::ptr_eq(&a, &b));
assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome");
std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap();
assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache");
let _ = std::fs::remove_dir_all(&dir);
}
/// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip
/// that isn't one), `short` (the body ends before its Content-Length), anything else 404.
#[cfg(not(target_arch = "wasm32"))]
fn serve() -> String {
use std::io::{BufRead, BufReader, Write};
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes());
std::thread::spawn(move || {
for conn in l.incoming() {
let Ok(mut c) = conn else { continue };
let mut r = BufReader::new(c.try_clone().unwrap());
let mut line = String::new();
let _ = r.read_line(&mut line);
loop {
let mut h = String::new();
if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() {
break;
}
}
let path = line.split(' ').nth(1).unwrap_or("").to_string();
let (status, body, len): (&str, Vec<u8>, Option<usize>) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) {
("good", true) => ("200 OK", zip.clone(), None),
("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None),
("garbage", true) => ("200 OK", b"<html>not a zip</html>".to_vec(), None),
("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())),
_ => ("404 Not Found", b"nope".to_vec(), None),
};
let n = len.unwrap_or(body.len());
let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes());
let _ = c.write_all(&body);
}
});
format!("http://127.0.0.1:{port}")
}
#[cfg(not(target_arch = "wasm32"))]
fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome {
s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap();
assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some());
let started = std::time::Instant::now();
loop {
if let Some(o) = s.place_names.take_outcome() {
// the activity row ends with the thread
while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(s.activity.list().is_empty());
return o;
}
assert!(started.elapsed().as_secs() < 60, "the download never ended");
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn download_end_to_end_and_its_failures() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
let base = serve();
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err());
for bad in ["missing", "garbage", "short"] {
let o = download(&mut s, &format!("{base}/{bad}/"), &dir);
let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") };
assert!(e.contains("try again"), "{bad}: {e}");
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}");
assert_eq!(st["lastError"].as_str(), Some(e.as_str()));
assert!(s.place_names.take_outcome().is_none(), "reported once");
}
assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2));
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2)));
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
let m = s.catalog.photo(ids[0]).unwrap().meta.clone();
// admin1 was a 404 (optional): the state stays blank, the rest is filled
assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn download_base_is_https_or_loopback_only() {
assert!(download::base_allowed("https://download.geonames.org/export/dump/"));
assert!(download::base_allowed("http://127.0.0.1:8080"));
assert!(download::base_allowed("http://127.0.0.1/dump"));
assert!(!download::base_allowed("http://127.0.0.1.example.com/"));
assert!(!download::base_allowed("http://127.0.0.1@example.com/"));
assert!(!download::base_allowed("http://download.geonames.org/"));
}
}
+85 -42
View File
@@ -4,7 +4,7 @@
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, 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};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
@@ -82,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
}
let on_deleted = match str_param(p, "onDeleted") {
Some(v) => {
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))?
}
None => Default::default(),
};
@@ -149,6 +149,24 @@ fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
}
/// The targets an album can hold and how many were browsed (Local) photos, which album views
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
let before = targets.len();
let kept: Vec<PhotoId> = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect();
let skipped = before - kept.len();
(kept, skipped)
}
/// Album targets, or the error for a selection that was nothing but Local photos.
fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Result<(Vec<PhotoId>, usize)> {
let (kept, skipped) = album_targets(s, targets);
if kept.is_empty() && skipped > 0 {
return Err(bad(command, super::LOCAL_ONLY));
}
Ok((kept, skipped))
}
/// Apply one op per target as a single undo step.
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
let targets = s.targets(p);
@@ -866,22 +884,31 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
// ---- albums
cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| {
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
if name.is_empty() {
return Err(bad("album.create", "empty name"));
cmd!(
"album.create",
"New Album",
["File"],
None,
"{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
if name.is_empty() {
return Err(bad("album.create", "empty name"));
}
let folder = bool_or(p, "folder", false);
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let (photos, skipped) =
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
let id = s.catalog.alloc_album_id();
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, quick: false, order: None } },
)?;
Ok(json!({"id": id.0, "skipped": skipped}))
}
let folder = bool_or(p, "folder", false);
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
let id = s.catalog.alloc_album_id();
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, quick: false, order: None } },
)?;
Ok(json!({"id": id.0}))
}),
),
cmd!(
"album.createSmart",
"New Smart Album…",
@@ -1103,34 +1130,44 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": changed}))
}
),
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
}
let mut photos = al.photos.clone();
let before = photos.len();
for t in targets {
if !photos.contains(&t) {
photos.push(t);
cmd!(
"album.addPhotos",
"Add to Album",
["Photo"],
None,
"{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error",
has_library_selection,
|s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
}
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
let mut photos = al.photos.clone();
let before = photos.len();
for t in targets {
if !photos.contains(&t) {
photos.push(t);
}
}
let added = photos.len() - before;
let cover = al.cover.or(photos.first().copied());
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added, "skipped": skipped}))
}
let added = photos.len() - before;
let cover = al.cover.or(photos.first().copied());
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,
"{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, skipped}; Local photos are skipped",
has_library_selection,
|s, p| {
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
let (targets, skipped) = targets;
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,
@@ -1154,7 +1191,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)}))
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped}))
}
),
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
@@ -1210,7 +1247,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Auto Import Now",
[],
None,
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — 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, import?}",
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — 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, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
always,
|s, p| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
@@ -1246,7 +1283,9 @@ pub fn specs() -> Vec<CommandSpec> {
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});
// a photo deleted from the library doesn't come back on its own because its file
// is still in the watched folder (a copy's source is not a known path)
let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"});
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),
@@ -1264,7 +1303,11 @@ pub fn specs() -> Vec<CommandSpec> {
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}))
let mut result = json!({"imported": r["imported"], "folder": folder});
if let Some(warnings) = r.get("warnings") {
result["warnings"] = warnings.clone();
}
Ok(result)
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
@@ -1326,7 +1369,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Photos",
["File"],
Some("Cmd+Shift+I"),
"{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?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), 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?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
always,
|s, p| {
let req = import_params(s, p)?;
+27 -11
View File
@@ -34,22 +34,34 @@ pub fn register_all(s: &Session) {
}
}
/// `.cube` files in `paths` (folders searched, zip bundles opened).
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't
/// be read with the reason (`[name, reason]`).
fn cube_files(paths: &[String]) -> (Vec<(String, String, Option<String>)>, Vec<Value>) {
// (name, text, group)
let mut out = Vec::new();
let mut failed = Vec::new();
let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
if 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));
match std::fs::read_to_string(&ep) {
Ok(t) => {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((stem(&ep), t, group));
}
Err(e) => failed.push(json!([stem(&ep), e.to_string()])),
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
let Ok(b) = std::fs::read(path) else { continue };
let b = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
failed.push(json!([stem(path), e.to_string()]));
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);
@@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
));
}
}
} 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));
} else {
match std::fs::read_to_string(path) {
Ok(t) => out.push((stem(path), t, None)),
Err(e) => failed.push(json!([stem(path), e.to_string()])),
}
}
}
out
(out, failed)
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
@@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
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 (files, mut failed) = cube_files(&paths);
let mut imported = Vec::new();
for (name, text, group) in files {
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
Ok(l) => l,
Err(e) => {
+59 -2
View File
@@ -690,12 +690,69 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.findBlemishes",
"Find Blemishes",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
has_active,
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
// 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 rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
let faces =
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
.iter()
.map(|f| lightcraft_pipeline::blemish::FaceBox {
x0: f64::from(f.x0),
y0: f64::from(f.y0),
x1: f64::from(f.x1),
y1: f64::from(f.y1),
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
})
.collect();
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
let mut per_face = std::collections::BTreeMap::new();
found.retain(|b| {
let n = per_face.entry(b.face).or_insert(0usize);
*n += 1;
*n <= max_per_face
});
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for b in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(b.x, b.y)],
size: b.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 Blemishes")?;
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
has_active,
|s, p| {
let mode: SpotMode =
@@ -719,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> {
};
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if spot.source_offset.is_none() {
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
// resolve the automatic source now: it gets a pin and stays put at every size
spot.source_offset = pick_source(s, id, &dd, &spot, None);
}
+10
View File
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
mod face_models;
mod face_recognize;
pub mod filters;
pub mod geocode;
pub mod keywords;
pub mod library;
pub mod lut_profiles;
@@ -100,6 +101,14 @@ pub fn has_active(s: &Session) -> std::result::Result<(), String> {
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
if s.selection.ids.is_empty() && s.selection.active.is_none() { Err("no photos selected".into()) } else { Ok(()) }
}
/// Albums hold library photos: a selection of only browsed (Local) photos has nothing an album
/// can take (issue #684; Add to My Photos first).
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
}
/// Why an album command refused a Local-only selection.
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
}
@@ -150,6 +159,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(missing::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(geocode::specs());
v.extend(filters::specs());
v.extend(denoise::specs());
v.extend(face_models::specs());
+125 -26
View File
@@ -52,16 +52,17 @@ impl PreviewBuild {
}
}
/// 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> {
/// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge`
/// (`None` = 1:1, the photo's full size), the larger first.
pub(crate) 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
let thumb = THUMB_SIZES[THUMB_SIZES.len() - 1];
let (view, grid) = (s.loupe_job(id, e, e, true), s.thumb_job(id, thumb));
// the larger render goes first: the one after it renders from the source it loaded (see
// `run_photo`), which must not be smaller than what it needs
if e >= thumb { view.into_iter().chain(grid).collect() } else { grid.into_iter().chain(view).collect() }
}
/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]).
@@ -76,17 +77,66 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard {
task
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard) {
for photo_jobs in jobs {
if state.cancel.load(Ordering::Relaxed) {
break;
/// Render one photo's jobs in order. The source the first job decodes serves the ones after it
/// (the grid thumbnail is made from the view's source instead of decoding the file again).
pub(crate) fn run_photo(jobs: Vec<RenderJob>) -> bool {
let mut decoded: Option<crate::media::DecodedSource> = None;
let mut ok = true;
for mut j in jobs {
if let Some(src) = &decoded
&& !matches!(j.source, crate::media::SourceRef::Loaded(_))
{
j.source = crate::media::SourceRef::Loaded(Box::new(src.clone()));
}
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
if !ok {
state.failed.fetch_add(1, Ordering::Relaxed);
let r = j.run();
ok &= r.rendered.is_ok();
if decoded.is_none() {
decoded = r.loaded;
}
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
task.progress(done as u64, state.total as u64);
}
ok
}
/// How many photos render side by side: as many as an export batch for standard previews (each
/// render spreads over the cores already; the others fill them while one decodes), two for 1:1
/// previews (a full-size source each).
fn lanes(n: usize, full: bool) -> usize {
let l = crate::export::export_parallelism(n);
if full { l.min(2) } else { l }
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {
let total = jobs.len();
let queue = std::sync::Mutex::new(jobs.into_iter());
let work = || {
loop {
if state.cancel.load(Ordering::Relaxed) {
break;
}
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
let Some(photo_jobs) = next else { break };
let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false);
if !ok {
state.failed.fetch_add(1, Ordering::Relaxed);
}
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
task.progress(done as u64, total as u64);
}
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::scope(|sc| {
for k in 1..lanes.max(1) {
// a lane that can't start leaves its photos to the others (and to this thread)
if std::thread::Builder::new().name(format!("previews-{k}")).spawn_scoped(sc, work).is_err() {
break;
}
}
work();
});
#[cfg(target_arch = "wasm32")]
{
let _ = lanes;
work();
}
state.finished.store(true, Ordering::Relaxed);
}
@@ -107,9 +157,10 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
s.preview_build = Some(state.clone());
let task = task_for(s, &state);
let lanes = lanes(jobs.len(), edge.is_none());
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
if wait {
run_all(jobs, &state, &task);
run_all(jobs, &state, &task, lanes);
return Ok(state.json());
}
#[cfg(not(target_arch = "wasm32"))]
@@ -118,7 +169,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
let st = state.clone();
std::thread::Builder::new()
.name("lc-build-previews".into())
.spawn(move || run_all(jobs, &st, &task))
.spawn(move || run_all(jobs, &st, &task, lanes))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
Ok(state.json())
@@ -126,7 +177,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
/// how to load the original at preview size, whether the photo takes the per-file camera look —
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those
/// proxies go stale when the fit changes).
#[cfg(not(target_arch = "wasm32"))]
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
@@ -192,6 +243,9 @@ fn smart_run(
break;
}
let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Read either the new shard or a pre-upgrade flat file, without making new work
// depend on having the original online.
let path = crate::smart::existing_file_path(dir, path);
if discard {
if std::fs::remove_file(&path).is_ok() {
n.removed += 1;
@@ -215,7 +269,14 @@ fn smart_run(
.ok_or_else(|| "nothing to build from".to_string())
.and_then(|s| s.load_source())
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
.and_then(|b| {
let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?;
// Only create the leaf shard: do not recreate a missing external drive.
if !parent.is_dir() {
std::fs::create_dir(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))
});
match r {
Ok(()) => {
n.built += 1;
@@ -270,9 +331,9 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
continue;
}
let path = dir.join(crate::smart::file_name(&ph));
let path = crate::smart::sharded_file_path(&dir, &ph);
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
jobs.push((id, path, source, ph.relative_wb()));
jobs.push((id, path, source, ph.fitted_look()));
}
let custom = s.smart_previews_dir.is_some();
if background {
@@ -314,9 +375,9 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
return None;
}
let max_edge = crate::media::SourceLevel::Preview.max_edge();
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect();
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect();
let jobs: Vec<SmartJob> =
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
std::thread::Builder::new()
.name("lc-smart-look".into())
.spawn(move || refresh_stale(&dir, jobs))
@@ -327,7 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
/// The body of [`refresh_stale_smart_previews`]: rebuild the stale proxies among `jobs`.
#[cfg(not(target_arch = "wasm32"))]
fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
let stale: Vec<SmartJob> = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect();
let stale: Vec<SmartJob> = jobs
.into_iter()
.map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look))
.filter(|(_, path, _, _)| crate::smart::is_stale(path))
.collect();
if !stale.is_empty() {
// `custom`: the folder is there already, and must not be recreated if it went away since
match smart_run(dir, true, false, stale, None) {
@@ -410,7 +475,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
/// 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))),
(Some(dir), Some(p)) => crate::smart::is_valid(&crate::smart::file_path(dir, p)),
_ => false,
}
}
@@ -465,3 +530,37 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{jobs_for, run_photo};
use crate::Session;
use crate::media::SourceRef;
// Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not
// by reading the file again (here: a thumbnail job pointing at a file that is not there)
#[test]
fn preview_build_makes_the_thumbnail_from_the_views_source() {
let mut s = Session::with_demo();
let id = s.active().unwrap();
let mut jobs = jobs_for(&mut s, id, Some(512));
assert_eq!(jobs.len(), 2, "view and thumbnail");
assert!(jobs[0].view_cache.is_some(), "the view renders first");
jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None };
assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source");
}
// Given many photos, a standard preview build renders them side by side and counts every one
#[test]
fn preview_build_of_many_photos_counts_each_once() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().map(|p| p.id.0).collect();
assert!(ids.len() > 4);
let r = s.execute("library.buildPreviews", &json!({"size": "standard", "edge": 512, "ids": ids, "wait": true})).unwrap();
assert_eq!(r["done"], json!(ids.len()), "{r}");
assert_eq!(r["total"], json!(ids.len()));
assert_eq!(r["failed"], json!(0));
}
}
+22 -2
View File
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
}
}
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
// an out-of-range offset to zero, returning the first page instead of no photos.
fn pagination_usize(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
s.visible_cloned()
};
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
Ok(json!({"total": ids.len(), "photos": items}))
}),
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
#[cfg(test)]
mod pagination_tests {
use super::*;
#[test]
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
assert_eq!(pagination_usize(0), 0);
assert_eq!(pagination_usize(200), 200);
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
#[cfg(target_pointer_width = "32")]
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
}
}
+10 -4
View File
@@ -35,11 +35,13 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut read = Vec::new();
let mut failed = Vec::new();
let mut warnings = Vec::new();
for id in s.targets(p) {
match s.read_sidecar_op(id) {
Ok(Some((op, from))) => {
match s.read_sidecar_op_with_warnings(id) {
Ok(Some((op, from, mut photo_warnings))) => {
ops.push(op);
read.push(json!({"id": id.0, "from": from.display().to_string()}));
warnings.append(&mut photo_warnings);
}
Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})),
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
@@ -48,7 +50,11 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
if !ops.is_empty() {
s.commit("Read Metadata from File", Op::Batch { ops })?;
}
Ok(json!({"read": read, "failed": failed}))
let mut result = json!({"read": read, "failed": failed});
if !warnings.is_empty() {
result["warnings"] = serde_json::to_value(warnings).unwrap_or_default();
}
Ok(result)
}
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
@@ -84,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Metadata from File",
["Photo"],
None,
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed}",
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
has_selection,
read
),
+118 -10
View File
@@ -3,24 +3,45 @@
//!
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic.
use std::ffi::OsString;
use std::path::PathBuf;
use crate::Session;
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
#[derive(Clone, Copy)]
enum Platform {
Mac,
Windows,
Other,
}
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
pub fn config_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
let platform = if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
Platform::Windows
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
Platform::Other => {
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("lightcraft"))
}
}
}
@@ -37,7 +58,25 @@ pub fn default_denoise_models_dir() -> Option<PathBuf> {
.or_else(|| config_dir().map(|d| d.join("denoise-models")))
}
/// Where the SAM 3 model is kept: `$LIGHTCRAFT_SAM3_DIR` if set, else `<config>/models/sam3`.
pub fn default_sam3_dir() -> Option<PathBuf> {
sam3_dir(std::env::var_os("LIGHTCRAFT_SAM3_DIR"), config_dir())
}
/// [`default_sam3_dir`] for a given override and config folder: a non-empty override wins.
fn sam3_dir(var: Option<std::ffi::OsString>, config: Option<PathBuf>) -> Option<PathBuf> {
var.filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config.map(|d| d.join("models").join("sam3")))
}
impl Session {
/// Look for the SAM 3 model in the shared default folder ([`default_sam3_dir`]), and for the user's
/// download mirrors in `<config>/models/sam3-mirrors.txt`.
pub fn with_default_sam3_model(mut self) -> Self {
self.segmenter.dir = default_sam3_dir();
self.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
self
}
/// Keep face models in the shared default folder ([`default_face_models_dir`]).
pub fn with_default_face_models(mut self) -> Self {
self.face_models_dir = default_face_models_dir();
@@ -56,3 +95,72 @@ impl Session {
self.denoise.touch();
}
}
#[cfg(test)]
mod config_tests {
use super::*;
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
#[test]
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
None
);
}
}
#[test]
fn config_unset_override_keeps_platform_defaults() {
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/LightCraft")));
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/LightCraft")));
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/lightcraft")));
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/lightcraft")));
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(resolve(platform, &[]), None);
}
}
#[cfg(unix)]
#[test]
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A non-empty `LIGHTCRAFT_SAM3_DIR` wins; unset or empty falls back to `<config>/models/sam3` (issue #619).
#[test]
fn sam3_dir_prefers_the_override() {
let config = PathBuf::from("cfg");
let fallback = Some(config.join("models").join("sam3"));
assert_eq!(sam3_dir(Some("elsewhere".into()), Some(config.clone())), Some(PathBuf::from("elsewhere")));
assert_eq!(sam3_dir(None, Some(config.clone())), fallback);
assert_eq!(sam3_dir(Some(std::ffi::OsString::new()), Some(config)), fallback);
assert_eq!(sam3_dir(None, None), None);
}
}
+6 -3
View File
@@ -138,7 +138,8 @@ impl Prepared {
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
inset_x: 0.0,
inset_y: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
@@ -305,7 +306,8 @@ mod tests {
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
inset_x: 0.0,
inset_y: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
@@ -320,7 +322,8 @@ mod tests {
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
inset_x: 0.0,
inset_y: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
+117 -22
View File
@@ -19,10 +19,10 @@ use serde_json::{Map, Value, json};
pub type Props = BTreeMap<String, Vec<String>>;
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its
/// as-shot look (rendered files, and raws whose readers have no illuminant,
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
/// with a Kelvin scale (colour matrices in the file, or measured for its camera) takes `Temperature`
/// as Kelvin; everything developed relative to its as-shot look (rendered files, and raws without
/// matrices anywhere, [`lightcraft_catalog::relative_wb_camera`]) needs a shift from the other app's
/// as-shot white instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
@@ -30,8 +30,8 @@ pub enum Target {
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
/// relative scale.
Rendered,
/// A raw developed with an absolute white balance (DNG and other files with a measured
/// illuminant): Kelvin as written.
/// A raw developed with an absolute white balance (DNG and other files with matrices, and raws
/// whose camera has measured matrices): Kelvin as written.
RawAbsolute,
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
RawRelative,
@@ -39,11 +39,12 @@ pub enum Target {
impl Target {
/// The target for a file of this media kind and format (its extension or Lightroom's
/// `fileFormat` name); `preview_only` for a raw that can't be decoded yet.
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
/// `fileFormat` name) shot on `camera` (Exif make and model as the catalog keeps them, or
/// empty when unknown); `preview_only` for a raw that can't be decoded yet.
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool, camera: &str) -> Target {
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
Target::Rendered
} else if lightcraft_catalog::relative_wb_format(format) {
} else if lightcraft_catalog::relative_wb_camera(format, camera) {
Target::RawRelative
} else {
Target::RawAbsolute
@@ -51,7 +52,7 @@ impl Target {
}
/// The target for a photo in the catalog.
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
Target::for_file(p.kind, &p.format, p.preview_only.is_some(), &p.meta.camera)
}
}
@@ -201,6 +202,10 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
n(o, "Shadows2012", "light.shadows");
n(o, "Whites2012", "light.whites");
n(o, "Blacks2012", "light.blacks");
// Lightroom's Light panel "HDR" button
if let Some(on) = boolean(props, "crs:HDREditMode") {
put(o, "hdr.enabled", json!(on));
}
// 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();
@@ -272,6 +277,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
if let Some(tint) = tint {
put(o, "wb.tint", json!(tint));
}
// Kelvin as the other app wrote it: say so, so the value stays Kelvin on a raw with that scale (a
// preset's Kelvin is read as the relative scale on the files that have only that, as before)
if wb == abs && matches!(target, Target::RawAbsolute | Target::Any) {
put(o, "wb.scale", json!("kelvin"));
}
}
// a custom Kelvin with nothing to shift it from: read on the relative scale it would be a
// large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so
@@ -462,18 +472,24 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}")));
}
let (spots, skipped) = crate::crs_masks::spots(values, aspect);
if !spots.is_empty() {
put(&mut out, "spots", Value::Array(spots));
}
if values.keys().any(|k| crate::crs_masks::SPOT_CONTAINERS.contains(&k.as_str())) {
read.extend(crate::crs_masks::SPOT_CONTAINERS.iter().map(|c| c.to_string()));
mask_skips.extend(skipped.into_iter().map(|k| format!("Spot: {k}")));
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
// HDR edit mode that is off, and Point Color slots that are all empty (-1)
// fields that only switch a panel on/off or name things: not adjustments by themselves; and
// Point Color slots that are all empty (-1)
let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v));
let off = |v: &[String]| v.iter().all(|s| s.trim() == "0");
let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::<f64>().is_ok_and(|x| x == -1.0) || n.trim().is_empty()));
let quiet = |k: &str| {
k.starts_with("Enable")
|| k.starts_with("ToneCurveName")
|| k == "AutoTone"
|| k == "AutoGrayscaleMix"
|| (k == "HDREditMode" && value_is(k, &off))
|| (k == "PointColors" && value_is(k, &empty_points))
};
let mut unmapped: Vec<String> = props
@@ -489,6 +505,22 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
(out, unmapped)
}
/// The selected camera profile name from an XMP packet. The name is metadata only: it does not
/// identify or carry the profile's rendering data.
pub fn camera_profile_name(props: &Props) -> Option<&str> {
first(props, "crs:CameraProfile")
}
/// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a
/// coordinate convention that LightCraft does not currently import.
pub fn has_saved_upright_transform(props: &Props) -> bool {
props.keys().any(|key| {
key.strip_prefix("crs:").is_some_and(|name| {
name.starts_with("UprightTransform_") && props.get(key).is_some_and(|values| values.iter().any(|v| !v.trim().is_empty()))
})
})
}
/// 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> {
@@ -593,6 +625,29 @@ mod tests {
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
}
#[test]
fn hdr_edit_mode_turns_hdr_editing_on_or_off() {
let x = |v: &str| {
format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:HDREditMode="{v}" crs:Exposure2012="+1.5"/></rdf:RDF>"#
)
};
let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0);
assert!(on.hdr.enabled);
assert_eq!(on.light.exposure, 1.5);
assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults");
let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0);
assert!(true_form.hdr.enabled);
let mut hdr = DevelopSettings::default();
hdr.hdr.enabled = true;
assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off");
// without the field nothing about HDR changes
let plain = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="+1.5"/></rdf:RDF>"#;
assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none());
}
#[test]
fn element_form_bw_and_incremental_wb() {
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
@@ -643,6 +698,10 @@ mod tests {
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0);
// Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin");
assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin");
assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none());
// the as-shot fields are a reference, not an adjustment: nothing to report
assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty());
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
@@ -674,14 +733,50 @@ mod tests {
#[test]
fn target_follows_the_file() {
use lightcraft_catalog::MediaKind;
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative);
// Lightroom's `fileFormat` name for proprietary raws
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute);
// a raw shown from its embedded preview develops like a rendered file
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered);
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered);
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered);
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered);
// a camera with measured matrices edits in Kelvin (issue #730), under any of its names
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute);
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute);
assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute);
assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute);
let mut p =
lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, "");
p.kind = MediaKind::Raw;
assert_eq!(Target::for_photo(&p), Target::RawRelative);
p.meta.camera = "SONY ILCE-7M3".into();
assert_eq!(Target::for_photo(&p), Target::RawAbsolute);
}
/// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value
/// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on
/// a raw without matrices still takes the #510 shift.
#[test]
fn kelvin_is_direct_on_raws_whose_camera_has_matrices() {
use lightcraft_catalog::MediaKind;
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Custom" crs:Temperature="4650" crs:Tint="+3" crs:AsShotTemperature="5400" crs:AsShotTint="-1"/>"#;
let p = props(x);
let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3");
assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"}));
let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2");
let shifted = to_partial(&p, a7m2);
assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}");
assert_eq!(shifted["wb"]["tint"], 4.0);
assert!(shifted["wb"].get("scale").is_none());
// without the reference the Kelvin camera still reads it, the other stays As Shot
let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, ""));
assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0);
assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"}));
// a named preset carries no numbers either way
let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#));
assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"}));
}
#[test]
@@ -754,10 +849,10 @@ mod tests {
fn preset_bookkeeping_is_not_reported_but_real_gaps_are() {
let empty_points = "<crs:PointColors><rdf:Seq><rdf:li>-1.000000, -1.000000, -1.000000, -1.000000</rdf:li></rdf:Seq></crs:PointColors>";
assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::<String>::new());
// an HDR edit, a used Point Color slot and an unknown adjustment still are
// a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over)
let used_points = "<crs:PointColors><rdf:Seq><rdf:li>0.5, 0.2, 0.1, 10, 0, 0, 0, 0</rdf:li></rdf:Seq></crs:PointColors>";
let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points));
assert_eq!(got, ["FutureSlider", "HDREditMode", "PointColors"]);
assert_eq!(got, ["FutureSlider", "PointColors"]);
}
#[test]
+201
View File
@@ -224,6 +224,120 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
(out, skipped)
}
/// The containers spot removal is stored in: `RetouchAreas` (current, one struct per spot with a
/// mask component) and `RetouchInfo` (older, one `key = value, …` line per spot; still written
/// beside `RetouchAreas` for older readers and read on its own when that is all there is).
pub const SPOT_CONTAINERS: [&str; 2] = ["crs:RetouchAreas", "crs:RetouchInfo"];
/// One `RetouchAreas` item → a [`Spot`](lightcraft_develop::Spot) as JSON. `aspect` = width /
/// height of the target: horizontal sizes are stored as fractions of the width, ours of the long
/// edge. Returns the component kind it can't carry over.
fn spot(item: &XmpValue, aspect: f64) -> Result<Value, String> {
let mode = match text(item, "crs:SpotType") {
Some("clone") => "clone",
_ => "heal",
};
let comps = item.field("crs:Masks").map(XmpValue::items).unwrap_or_default();
let Some(m) = comps.first() else { return Err("spot without a mask".into()) };
let width_to_long = aspect.min(1.0);
let (points, size) = match text(m, "crs:What").unwrap_or("") {
// a circle: centre + half-sizes in image fractions
"Mask/Ellipse" => {
let (Some(x), Some(y), Some(sx)) = (num(m, "crs:X"), num(m, "crs:Y"), num(m, "crs:SizeX")) else {
return Err("ellipse spot".into());
};
(vec![pt(x, y)], sx * width_to_long)
}
// the shape exiftool's schema also allows for a spot
"Mask/CircularGradient" => {
let (Some(t), Some(l), Some(b), Some(r)) = (num(m, "crs:Top"), num(m, "crs:Left"), num(m, "crs:Bottom"), num(m, "crs:Right")) else {
return Err("circular spot".into());
};
(vec![pt((l + r) / 2.0, (t + b) / 2.0)], (r - l).abs() / 2.0 * width_to_long)
}
// a brushed removal: the dabs are the path
"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 brushed spot".into());
}
(pts, num(m, "crs:Radius").unwrap_or(0.02))
}
other => return Err(if other.is_empty() { "unknown spot".into() } else { other.trim_start_matches("Mask/").to_string() }),
};
let size = size.clamp(0.001, 0.25);
// the source is stored as its absolute position (`SourceX`, and `OffsetY` despite the name);
// ours is the offset from the target; anything but an explicit source leaves it automatic
let explicit = text(item, "crs:SourceState").is_none_or(|s| s == "sourceSetExplicitly");
let source_offset = match (explicit, num(item, "crs:SourceX"), num(item, "crs:OffsetY"), points.first()) {
(true, Some(sx), Some(sy), Some(p)) => {
let (Some(x), Some(y)) = (p["x"].as_f64(), p["y"].as_f64()) else { return Err("spot centre".into()) };
let (dx, dy) = (sx - x, sy - y);
(dx.is_finite() && dy.is_finite() && (dx != 0.0 || dy != 0.0)).then(|| pt(dx, dy))
}
_ => None,
};
let feather = (num(item, "crs:Feather").unwrap_or(0.0) * 100.0).clamp(0.0, 100.0);
let opacity = (num(item, "crs:Opacity").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0);
Ok(json!({"mode": mode, "points": points, "size": size, "feather": feather, "opacity": opacity, "source_offset": source_offset}))
}
/// One `RetouchInfo` line (`centerX = 0.5, centerY = 0.5, radius = 0.01, sourceState = …,
/// sourceX = …, sourceY = …, spotType = heal`) → a spot as JSON.
fn legacy_spot(line: &str, aspect: f64) -> Option<Value> {
let mut kv = BTreeMap::new();
for part in line.split(',') {
if let Some((k, v)) = part.split_once('=') {
kv.insert(k.trim().to_string(), v.trim().to_string());
}
}
let f = |k: &str| kv.get(k).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
let (x, y, r) = (f("centerX")?, f("centerY")?, f("radius")?);
let mode = if kv.get("spotType").is_some_and(|s| s == "clone") { "clone" } else { "heal" };
let explicit = kv.get("sourceState").is_none_or(|s| s == "sourceSetExplicitly");
let source_offset = match (explicit, f("sourceX"), f("sourceY")) {
(true, Some(sx), Some(sy)) if sx != x || sy != y => Some(pt(sx - x, sy - y)),
_ => None,
};
let opacity = (f("opacity").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0);
Some(
json!({"mode": mode, "points": [pt(x, y)], "size": (r * aspect.min(1.0)).clamp(0.001, 0.25), "feather": 0.0, "opacity": opacity, "source_offset": source_offset}),
)
}
/// Spot removal in `values` (JSON for `DevelopSettings::spots`), plus the kinds of spot that
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
pub fn spots(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut skipped = Vec::new();
if let Some(list) = values.get("crs:RetouchAreas") {
for item in list.items() {
match spot(item, aspect) {
Ok(v) => out.push(v),
Err(kind) => skipped.push(kind),
}
}
}
if out.is_empty()
&& let Some(list) = values.get("crs:RetouchInfo")
{
out.extend(list.items().iter().filter_map(XmpValue::text).filter_map(|l| legacy_spot(l, aspect)));
}
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;
@@ -376,4 +490,91 @@ mod tests {
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");
}
/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
/// a healed circle with an explicit source, a cloned circle with an automatic source, a
/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
const SPOTS: &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.1">
<crs:RetouchInfo><rdf:Seq>
<rdf:li>centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal</rdf:li>
</rdf:Seq></crs:RetouchInfo>
<crs:RetouchAreas><rdf:Seq>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:Method="poisson" crs:SourceX="0.45" crs:OffsetY="0.3" crs:Opacity="1" crs:Feather="0" crs:Seed="1">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:MaskActive="true" crs:MaskValue="1" crs:X="0.4" crs:Y="0.3" crs:SizeX="0.01" crs:SizeY="0.015"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="clone" crs:SourceState="sourceAutoComputed" crs:Opacity="0.5" crs:Feather="0.25">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:X="0.7" crs:Y="0.6" crs:SizeX="0.02" crs:SizeY="0.02"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:SourceX="0.2" crs:OffsetY="0.9">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Paint" crs:Radius="0.03" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.8</rdf:li><rdf:li>d 0.12 0.82</rdf:li><rdf:li>d 0.14 0.84</rdf:li></rdf:Seq></crs:Dabs></rdf:li></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceX="0.6" crs:OffsetY="0.5">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/CircularGradient" crs:Top="0.48" crs:Left="0.49" crs:Bottom="0.52" crs:Right="0.51"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Gradient" crs:FullX="0" crs:FullY="0" crs:ZeroX="1" crs:ZeroY="1"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:RetouchAreas>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn retouch_areas_become_spots() {
use lightcraft_develop::SpotMode;
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
assert_eq!(s.light.exposure, 0.1, "global settings still map");
assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots);
let heal = &s.spots[0];
assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0));
assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9);
assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions");
let src = heal.source_offset.expect("explicit source");
assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}");
let clone = &s.spots[1];
assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None));
let brushed = &s.spots[2];
assert_eq!((brushed.points.len(), brushed.size), (3, 0.03));
let src = brushed.source_offset.expect("explicit source");
assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9);
let circ = &s.spots[3];
assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9);
assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}");
}
#[test]
fn portrait_target_scales_spot_sizes() {
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0);
let s = DevelopSettings::default().merged(&partial).unwrap();
assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size);
}
#[test]
fn legacy_retouch_info_is_read_on_its_own() {
let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored");
let d = lightcraft_meta::parse_xmp(&packet).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).unwrap();
assert_eq!(s.spots.len(), 1, "{:?}", s.spots);
assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal);
assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9);
assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9));
assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}");
assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}");
}
#[test]
fn spots_render() {
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).unwrap();
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 spots change the picture");
}
}
+296 -15
View File
@@ -157,6 +157,97 @@ struct Network {
fallbacks: AtomicUsize,
/// Tiles at once of the photo started last (0: none yet), to say where the work runs.
last_threads: AtomicUsize,
/// Another computer's graphics card (`LIGHTCRAFT_DENOISE_URL` + `LIGHTCRAFT_DENOISE_TOKEN`; see
/// `docs/denoise.md` → Another computer's graphics card), greeted on first use: it takes tiles while it has a free
/// slot, on top of this computer's runner.
remote: Option<RemoteSide>,
/// Whether this computer's share runs on the card (else the CPU), as chosen for the photo started last.
local_card: AtomicBool,
}
#[cfg(feature = "denoise")]
struct RemoteSide {
runner: lightcraft_denoise::remote::Remote,
/// The greeting, and the server's time for the check tile there and back (ms; the faster of two runs).
greeted: OnceLock<Result<(lightcraft_denoise::remote::ServerInfo, f64), String>>,
/// Whether the photo started last shares tiles with the server, and if not because it is slower, the two times.
share: AtomicBool,
slower: Mutex<Option<(f64, f64)>>,
}
#[cfg(feature = "denoise")]
impl RemoteSide {
/// From the environment, for the model whose file has `sha256` and tiles of `tile` cells.
fn from_env(sha256: &str, tile: usize) -> Option<RemoteSide> {
let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let address = var("LIGHTCRAFT_DENOISE_URL")?;
let token = var("LIGHTCRAFT_DENOISE_TOKEN").unwrap_or_default();
Some(RemoteSide::new(lightcraft_denoise::remote::Remote::new(&address, &token, sha256, tile)))
}
fn new(runner: lightcraft_denoise::remote::Remote) -> RemoteSide {
RemoteSide { runner, greeted: OnceLock::new(), share: AtomicBool::new(false), slower: Mutex::new(None) }
}
/// Greet the server and time a check tile there and back: a server behind a slow link can take seconds a tile, and
/// handing it tiles a fast local card would finish sooner only makes the photo wait for it.
fn greet(&self) -> Result<(lightcraft_denoise::remote::ServerInfo, f64), String> {
let info = self.runner.hello().map_err(|e| e.to_string())?;
let input = lightcraft_denoise::runtime::check_tile(info.tile);
let mut best = f64::INFINITY;
for _ in 0..2 {
let started = web_time::Instant::now();
match self.runner.try_run(&input) {
Some(Ok(_)) => best = best.min(started.elapsed().as_secs_f64() * 1000.0),
Some(Err(e)) => return Err(format!("the check tile failed: {e}")),
None => return Err("no free slot for the check tile".into()),
}
}
Ok((info, best))
}
/// The server when it can take tiles now (greeting it the first time), else why not.
fn usable(&self, start: bool) -> Result<&lightcraft_denoise::remote::Remote, String> {
let greeted = if start { Some(self.greeted.get_or_init(|| self.greet())) } else { self.greeted.get() };
match greeted {
None => Err("not contacted yet".into()),
Some(Err(why)) => Err(why.clone()),
Some(Ok(_)) if !self.runner.healthy() => {
Err(format!("it failed {} tiles ({})", self.runner.failures(), self.runner.last_error().unwrap_or_default()))
}
Some(Ok(_)) => Ok(&self.runner),
}
}
/// The server's time for a tile, once greeted.
fn tile_ms(&self) -> Option<f64> {
self.greeted.get().and_then(|g| g.as_ref().ok()).map(|g| g.1)
}
fn describe(&self) -> Value {
match self.greeted.get() {
None => json!({"address": self.runner.address(), "state": "pending"}),
Some(Err(why)) => json!({"address": self.runner.address(), "state": "unavailable", "reason": why}),
Some(Ok((info, ms))) => {
let slower = *self.slower.lock().unwrap_or_else(PoisonError::into_inner);
let state = match () {
_ if !self.runner.healthy() => "given up",
_ if slower.is_some() => "slower",
_ if self.share.load(Ordering::Relaxed) => "used",
_ => "idle",
};
let mut v =
json!({"address": info.address, "device": info.device, "state": state, "remoteMs": ms, "failures": self.runner.failures()});
if let (Some((_, local)), Some(o)) = (slower, v.as_object_mut()) {
o.insert("localMs".into(), json!(local));
}
if let (Some(e), Some(o)) = (self.runner.last_error(), v.as_object_mut()) {
o.insert("lastError".into(), json!(e));
}
v
}
}
}
}
#[cfg(feature = "denoise")]
@@ -338,10 +429,19 @@ impl Network {
}
}
/// The card, with the CPU running any tile it gets wrong. Handed out only for photos that run on the card.
/// A remote server's free slot first (when there is one), then the card when this photo runs on it, with the CPU
/// running any tile either gets wrong. Handed out for photos that run on the card or have a server to share.
#[cfg(feature = "denoise")]
impl TileRunner for Network {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, RunError> {
if let Some(Ok(remote)) = self.remote.as_ref().filter(|r| r.share.load(Ordering::Relaxed)).map(|r| r.usable(false))
&& let Some(Ok(out)) = remote.try_run(input)
{
return Ok(out);
}
if !self.local_card.load(Ordering::Relaxed) {
return self.cpu.run(input);
}
if let Ok(g) = self.card(false) {
match g.runner.run(input) {
Ok(out) if out.iter().all(|v| v.is_finite()) => return Ok(out),
@@ -361,10 +461,37 @@ impl Model for Network {
fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) {
let threads = threads.max(1);
self.last_threads.store(threads, Ordering::Relaxed);
match self.choose(run_on, threads, true) {
Ok(_) => (self, threads.min(GPU_PARALLEL)),
Err(_) => (&self.cpu, threads),
let (card, local) = match self.choose(run_on, threads, true) {
Ok(_) => (true, threads.min(GPU_PARALLEL)),
Err(_) => (false, threads),
};
self.local_card.store(card, Ordering::Relaxed);
if let Some(r) = &self.remote {
// this computer's time for a tile: the card's, or the CPU's with `threads` tiles sharing it (as
// `card_is_faster` counts them); unknown when the card was never set up (then the server is used)
let local_ms = match self.gpu.get() {
Some(Ok(g)) if card => g.card_ms,
Some(Ok(g)) => g.cpu_ms / (1.0 + 0.5 * threads.saturating_sub(1) as f64),
_ => f64::INFINITY,
};
let share = match (r.usable(true), r.tile_ms()) {
(Ok(_), Some(ms)) if ms < local_ms => {
*r.slower.lock().unwrap_or_else(PoisonError::into_inner) = None;
true
}
(Ok(_), Some(ms)) => {
*r.slower.lock().unwrap_or_else(PoisonError::into_inner) = Some((ms, local_ms));
false
}
_ => false,
};
r.share.store(share, Ordering::Relaxed);
if let (true, Ok(remote)) = (share, r.usable(false)) {
// the server's slots come on top of this computer's own tiles at once
return (self, local + remote.slots());
}
}
if card { (self, local) } else { (&self.cpu, local) }
}
fn self_test(&self, run_on: RunOn) -> Result<Value, String> {
@@ -403,6 +530,9 @@ impl Model for Network {
o.insert("cpuMs".into(), json!(g.cpu_ms));
o.insert("fellBack".into(), json!(self.fallbacks.load(Ordering::Relaxed)));
}
if let (Some(r), Some(o)) = (&self.remote, v.as_object_mut()) {
o.insert("remote".into(), r.describe());
}
v
}
}
@@ -412,15 +542,14 @@ pub(crate) fn default_loader() -> Loader {
#[cfg(feature = "denoise")]
{
Arc::new(|path, manifest| {
let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?;
Ok(Arc::new(Network {
cpu,
path: path.to_path_buf(),
tile: manifest.tile as usize,
gpu: OnceLock::new(),
fallbacks: AtomicUsize::new(0),
last_threads: AtomicUsize::new(0),
}) as Arc<dyn Model>)
// the server must run this very file: identified by the manifest's hash, else the file's
let sha256 = match &manifest.sha256 {
Some(h) => Some(h.clone()),
None if std::env::var_os("LIGHTCRAFT_DENOISE_URL").is_some() => lightcraft_denoise::hash::sha256_file(path).ok(),
None => None,
};
let remote = sha256.and_then(|h| RemoteSide::from_env(&h, manifest.tile as usize));
Ok(Arc::new(network(path, manifest, remote)?) as Arc<dyn Model>)
})
}
#[cfg(not(feature = "denoise"))]
@@ -429,6 +558,69 @@ pub(crate) fn default_loader() -> Loader {
}
}
/// The model at `path` on this computer, sharing tiles with `remote` when there is one.
#[cfg(feature = "denoise")]
fn network(path: &Path, manifest: &DenoiserManifest, remote: Option<RemoteSide>) -> Result<Network, String> {
let cpu = lightcraft_denoise::runtime::CpuRunner::load(path, manifest).map_err(|e| e.to_string())?;
Ok(Network {
remote,
local_card: AtomicBool::new(true),
cpu,
path: path.to_path_buf(),
tile: manifest.tile as usize,
gpu: OnceLock::new(),
fallbacks: AtomicUsize::new(0),
last_threads: AtomicUsize::new(0),
})
}
/// A model ready to be served to other computers (`lightcraft-cli denoise-serve`).
#[cfg(feature = "denoise")]
pub struct Serving {
pub model: lightcraft_denoise::remote::ModelId,
pub tile: usize,
/// Where its tiles run here: the graphics card's name, or why the CPU does.
pub device: String,
pub run: lightcraft_denoise::remote::RunTile,
}
/// The installed model `id` (default: the one selected in Settings) in the models folder `dir` (default: this user's),
/// set up to run on `run_on` as the app would: the card checked against the CPU first, the CPU when the card can't.
#[cfg(feature = "denoise")]
pub fn serving(dir: Option<&Path>, id: Option<&str>, run_on: RunOn) -> Result<Serving, String> {
let dir = match dir {
Some(d) => d.to_path_buf(),
None => crate::config::default_denoise_models_dir().ok_or("no denoise models folder (set LIGHTCRAFT_DENOISE_MODELS)")?,
};
let want = match id {
Some(i) => i.to_string(),
None => read_settings(&dir).model.ok_or_else(|| {
format!("no denoise model is selected in {} (install one in Settings ▸ AI Denoise, or name it with --model)", dir.display())
})?,
};
let installed = installed_models(&dir)
.into_iter()
.find(|m| m.manifest.id == want)
.ok_or_else(|| format!("no installed denoise model `{want}` in {}", dir.display()))?;
let m = &installed.manifest;
// the same identity a client derives (see `default_loader`)
let sha256 = match &m.sha256 {
Some(h) => h.clone(),
None => lightcraft_denoise::hash::sha256_file(&installed.onnx).map_err(|e| format!("{}: {e}", installed.onnx.display()))?,
};
let net = Arc::new(network(&installed.onnx, m, None)?);
let tile = m.tile as usize;
// set the card up now (and say where the tiles run) rather than with the first client's tile
let _ = net.runner(run_on, GPU_PARALLEL);
let d = net.device(run_on);
let device = match d["kind"].as_str() {
Some("gpu") => format!("GPU: {}", d["adapter"].as_str().unwrap_or("?")),
_ => format!("CPU ({})", d["reason"].as_str().unwrap_or("chosen")),
};
let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| net.runner(run_on, GPU_PARALLEL).0.run(input));
Ok(Serving { model: lightcraft_denoise::remote::ModelId { id: m.id.clone(), version: m.version.clone(), sha256 }, tile, device, run })
}
/// A model that is loaded on first use and kept, shared by the background job and the exports.
pub(crate) type ModelSlot = Arc<Mutex<Option<Arc<dyn Model>>>>;
@@ -1008,16 +1200,18 @@ impl Session {
}
/// Make the index of ready photos from the products on disk (a directory listing and a hash per photo: no file is
/// opened). Pictures that appeared or vanished drop the decoded sources they affect.
/// opened). Pictures that appeared or vanished drop the decoded sources they affect. The photo being made is its
/// job's to report (see [`Self::denoise_check_photo`]).
pub(crate) fn denoise_reindex(&mut self) {
self.denoise.dirty = false;
self.denoise.reindexed = Some((self.catalog.revision, web_time::Instant::now()));
let running = self.denoise.running.as_ref().map(|r| r.photo);
let mut now: HashMap<PhotoId, DenoiseSpec> = HashMap::new();
if let (Some(dir), Some(active)) = (self.denoise_products_dir(), self.denoise.active.as_ref()) {
let names: HashSet<String> =
products_in(&dir).into_iter().filter_map(|(p, _, _)| p.file_stem().and_then(|s| s.to_str()).map(str::to_string)).collect();
if !names.is_empty() {
for p in self.catalog.photos().filter(|p| eligible(p)) {
for p in self.catalog.photos().filter(|p| eligible(p) && running != Some(p.id)) {
let key = product_key(&active.fingerprint, p);
if names.contains(&key) {
now.insert(p.id, DenoiseSpec { product: product_path(&dir, &key), key, make: None });
@@ -1046,7 +1240,14 @@ impl Session {
}
/// A photo whose file changed (its content key is not the one its index entry was made for) has no picture.
///
/// The photo whose picture is being made is left to its job: the worker saves the picture before it reports, so the
/// file is on disk while the job still counts as running. Finding it here would make the photo Ready while the session
/// is busy with it (#719); [`Self::denoise_poll`] makes it Ready in the pump that takes the job's result instead.
fn denoise_check_photo(&mut self, id: PhotoId) {
if self.denoise.running.as_ref().is_some_and(|r| r.photo == id) {
return;
}
let Some(p) = self.catalog.photo(id).cloned() else { return };
let Some((spec, _)) = self.denoise_spec(&p) else { return };
match self.media.denoise.spec(id) {
@@ -1399,6 +1600,12 @@ impl Session {
self.denoise.made
}
/// The running job's product path and key (tests stand in for the worker's save).
#[cfg(test)]
pub(crate) fn denoise_running_product(&self) -> Option<(PathBuf, String)> {
self.denoise.running.as_ref().map(|r| (r.product.clone(), r.key.clone()))
}
/// What an export of photo `id` with `settings` needs for its Denoise amount: its denoised picture, made now when the
/// queue has not got to it. `Ok(None)` when denoise does not apply (amount 0, not a Bayer raw), `Err` when it should
/// have and cannot (no model chosen, nowhere to keep the picture, the model failing).
@@ -1502,6 +1709,76 @@ mod gpu_tests {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// A server on this computer running `cpu` for the model with `sha256`.
fn server(cpu: lightcraft_denoise::runtime::CpuRunner, sha256: &str, tile: usize) -> String {
use lightcraft_denoise::remote::{ModelId, ServerOptions, serve};
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap().to_string();
let id = ModelId { id: "synthetic".into(), version: "1".into(), sha256: sha256.into() };
let run: lightcraft_denoise::remote::RunTile = Arc::new(move |input: &[f32]| cpu.run(input));
let opts = ServerOptions { token: "remote-test-token-0123".into(), ..Default::default() };
std::thread::spawn(move || serve(l, id, tile, "CPU (test server)".into(), run, opts, Arc::new(|_| {})));
addr
}
fn with_remote(path: &Path, tile: u64, addr: &str, sha256: &str) -> Network {
let manifest = DenoiserManifest {
id: "synthetic".into(),
name: "Synthetic".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: String::new(),
domain: Domain::BayerToRgb,
tile: tile as u32,
overlap: 16,
gain: Gain::MatchMean { nominal: 1.0, max_deviation: 0.05 },
};
let remote = RemoteSide::new(lightcraft_denoise::remote::Remote::new(addr, "remote-test-token-0123", sha256, tile as usize));
network(path, &manifest, Some(remote)).unwrap()
}
#[test]
fn a_remote_server_shares_the_tiles_and_drops_out_when_it_cannot() {
let (_, cpu, path) = model("remote", 64);
let sha = lightcraft_denoise::hash::sha256_file(&path).unwrap();
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
let want = cpu.run(&input).unwrap();
// the same model on the server: its slots come on top of this computer's threads, answers within f16 rounding
let addr = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), &sha, 64);
let net = with_remote(&path, 64, &addr, &sha);
let (runner, at_once) = net.runner(RunOn::Cpu, 2);
assert_eq!(at_once, 2 + 4, "four server slots on top of two local threads");
assert!(worst(&want, &runner.run(&input).unwrap()) < 5e-3);
let d = net.device(RunOn::Cpu);
assert_eq!(d["remote"]["state"], "used", "{d}");
assert_eq!(d["remote"]["device"], "CPU (test server)");
// a local card faster than the server there and back (here a stand-in that takes no time): nothing is shared,
// the photo never waits for the slower server, and the status says why
let mut net = with_remote(&path, 64, &addr, &sha);
net.gpu = OnceLock::from(Ok(GpuSide { runner: Box::new(cpu.clone()), adapter: "Fast card".into(), card_ms: 1e-6, cpu_ms: 1000.0 }));
let (_, at_once) = net.runner(RunOn::Gpu, 8);
assert_eq!(at_once, GPU_PARALLEL, "no server slots");
let d = net.device(RunOn::Gpu);
assert_eq!(d["remote"]["state"], "slower", "{d}");
assert!(d["remote"]["remoteMs"].as_f64().is_some_and(|ms| ms > 0.0) && d["remote"]["localMs"].as_f64().is_some(), "{d}");
// a server running another model is refused: everything runs here
let other = server(lightcraft_denoise::runtime::CpuRunner::load(&path, &cpu.manifest().clone()).unwrap(), "0000", 64);
let net = with_remote(&path, 64, &other, &sha);
let (runner, at_once) = net.runner(RunOn::Cpu, 2);
assert_eq!(at_once, 2);
assert_eq!(runner.run(&input).unwrap(), want, "the CPU path, bit for bit");
let d = net.device(RunOn::Cpu);
assert!(d["remote"]["reason"].as_str().is_some_and(|r| r.contains("another model")), "{d}");
// nobody listening: the same
let net = with_remote(&path, 64, "127.0.0.1:1", &sha);
assert_eq!(net.runner(RunOn::Cpu, 3).1, 3);
assert_eq!(net.device(RunOn::Cpu)["remote"]["state"], "unavailable");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn with_the_processor_chosen_everything_runs_on_the_cpu() {
let (model, cpu, path) = model("cpu", 64);
@@ -1548,6 +1825,8 @@ mod gpu_tests {
gpu: OnceLock::from(Ok(card)),
fallbacks: AtomicUsize::new(0),
last_threads: AtomicUsize::new(0),
remote: None,
local_card: AtomicBool::new(true),
};
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
let want = cpu.run(&input).unwrap();
@@ -1576,6 +1855,8 @@ mod gpu_tests {
gpu: OnceLock::from(Ok(slow)),
fallbacks: AtomicUsize::new(0),
last_threads: AtomicUsize::new(0),
remote: None,
local_card: AtomicBool::new(true),
};
assert_eq!(model.runner(RunOn::Auto, 16).1, 16, "sixteen threads beat it");
let device = model.device(RunOn::Auto);
File diff suppressed because it is too large. Load diff
+320 -24
View File
@@ -44,12 +44,13 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
}
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the
/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the
/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES};
use lightcraft_geom::Point;
use lightcraft_raw::Opcode;
let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64);
if aw < 2.0 || ah < 2.0 {
return None;
@@ -69,21 +70,54 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop
(Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long)
};
let mut lens = EmbeddedLens::default();
for op in &raw.opcodes.list3 {
// opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional
// WarpRectilinear2)
let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect();
// A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output);
// without one the first WarpRectilinear is the lens warp, as it always was.
let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. }));
let mut stages = Vec::new();
for op in ops {
match op {
lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !planes.is_empty() && lens.warp.is_none() => {
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => {
let (center, radius) = centre(*center);
let p = |i: usize| planes[i.min(planes.len() - 1)];
stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal });
}
Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => {
// WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit
let (center, radius) = centre(*center);
let p = |i: usize| {
let k = planes[i.min(planes.len() - 1)];
let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN];
s[0] = k[0];
s[2] = k[1];
s[4] = k[2];
s[6] = k[3];
s[15] = k[4];
s[16] = k[5];
s[18] = 1e6;
s
};
stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false });
}
Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => {
let (center, radius) = centre(*center);
let p = |i: usize| planes[i.min(planes.len() - 1)];
lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius });
}
lightcraft_raw::Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
let (center, radius) = centre(*center);
lens.vignette = Some(EmbeddedVignette { k: *k, center, radius });
}
_ => {}
}
}
if lens.warp.is_none() && lens.vignette.is_none() {
// (more stages than a lens keeps: the first ones; real files carry two)
for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) {
*slot = Some(st);
}
if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() {
return None;
}
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
@@ -131,11 +165,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
if raw.orientation.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
let embedded_lens = embedded_lens(&raw);
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw)));
let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone());
return Ok(ProbeInfo {
embedded_lens,
width: w,
@@ -365,10 +397,11 @@ fn load_bytes_now(
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
let info = raw.info();
let lens = embedded_lens(&info);
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
// Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`).
let own_matrix = lightcraft_raw::color::has_matrix(&raw.color);
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info));
drop(bytes);
@@ -435,17 +468,17 @@ fn load_bytes_now(
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let twin = twin.map(|picture| finish(picture).0);
let (temp, tint) = xy_to_temp_tint(xy);
let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix;
// White balance re-evaluates the file's own colour model (when it has one and no
// file-local look matrix sits on top of it)
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
// White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| {
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
});
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let local_tone = local_tone(&raw);
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
let (temp, tint) = scale.as_shot(xy);
return Ok((
img,
twin,
@@ -454,7 +487,8 @@ fn load_bytes_now(
as_shot_temp: temp,
as_shot_tint: tint,
lens,
relative_wb: relative,
relative_wb: scale.relative,
legacy_relative_wb: scale.legacy_relative,
camera_color,
camera_tone,
mattes,
@@ -469,6 +503,47 @@ fn load_bytes_now(
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
}
/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from
/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step).
///
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look
/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
struct WbScaleOf {
own_matrix: bool,
relative: bool,
/// Kelvin through the camera's measured matrices: edited on the relative scale before those were used.
legacy_relative: bool,
/// The as-shot white through the measured matrices, when that is where the scale comes from.
spectral_white: Option<lightcraft_color::Xy>,
}
impl WbScaleOf {
fn new(
format: lightcraft_raw::RawFormat,
color: &lightcraft_raw::ColorData,
wb_multipliers: Option<[f32; 3]>,
meta: &lightcraft_meta::Metadata,
) -> Self {
let own_matrix = lightcraft_raw::color::has_matrix(color);
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten();
let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers));
WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white }
}
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
/// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog
/// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65.
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
if self.relative {
return (6500.0, 0.0);
}
let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own));
(t.round(), tint.round())
}
}
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
@@ -605,17 +680,144 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
/// Filesystem-backed embedded-preview hook (native).
///
/// Reads the whole file to find the preview, so it holds the memory gate for twice the file's size (the file and the
/// decoded preview): background work (the face scan) waits for room, interactive work is counted and never waits.
/// The quick path reads the file's header and its embedded preview and nothing else (see
/// [`preview_window`]); a format whose preview the header cannot point at falls back to the
/// whole-file path, which is what every format used to cost. The memory gate still counts the whole
/// file, which is what the decode of one can hold.
pub fn fs_preview_loader() -> PreviewLoader {
Arc::new(|path: &str, max_edge: usize| {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
return Some(image);
}
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
})
}
/// What [`preview_window`] read from a file, so a caller can see what a preview cost.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PreviewRead {
/// Bytes read.
pub read: u64,
/// The file's size.
pub total: u64,
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
/// every preview cost before the header path existed).
pub header_path: bool,
}
impl PreviewRead {
/// The share of the file that was read, `0.0`..=`1.0`.
pub fn fraction(self) -> f64 {
if self.total == 0 {
return 0.0;
}
(self.read as f64 / self.total as f64).clamp(0.0, 1.0)
}
}
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
/// which is the caller's cue to read the file whole.
///
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview`
/// still needs the whole container, so the header comes back with the window; for a preview taken
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
/// whole-file fallback stays in charge.
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
let mut prefix = read_prefix(&mut file, path, total)?;
// What the header itself declares, before the container reader is asked: it clamps a slice to
// the buffer it was given, so a prefix that stops inside the declared preview yields a
// self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF:
// a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent
// first costs nothing — it lives in the first bytes of every format covered here.
if let Some(extent) = raf_preview_extent(&prefix)
&& extent > prefix.len() as u64
{
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
}
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) };
// A preview at the file's end may have been padded past it: clamp rather than fail.
let end = range.offset.saturating_add(range.window_len).min(total);
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
// When the prefix already covers the range (the usual case: the prefix was grown to the declared
// extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview.
let prefix_len = prefix.len() as u64;
let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec);
let (window, bytes_read) = match from_prefix {
Some(window) => (window, prefix_len),
None => {
let mut window = vec![0u8; len];
file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?;
file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?;
(window, prefix_len + len as u64)
}
};
// A lying pointer must not cost the user their preview: hand the caller the whole-file fallback.
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
return Ok(None);
}
let read = PreviewRead { read: bytes_read, total, header_path: true };
// On a network share the bytes are the wait, so say what this preview actually cost. A window
// taken from the prefix cost nothing beyond the prefix itself.
if lightcraft_pipeline::profiling() {
eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len());
}
Ok(Some((prefix, window, read)))
}
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX));
read_prefix_at(file, path, len as u64)
}
/// The file's first `len` bytes, read from the start.
fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result<Vec<u8>, String> {
use std::io::{Read, Seek, SeekFrom};
let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?;
let mut prefix = vec![0u8; len];
file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?;
file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?;
Ok(prefix)
}
/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at
/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here
/// without the rest of the file so the prefix can be made big enough first.
fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
if !prefix.starts_with(b"FUJIFILMCCD-RAW") {
return None;
}
let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from);
let (offset, len) = (u32_at(84)?, u32_at(88)?);
(offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten()
}
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
/// `None` when the bytes are not a decodable image.
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
}
// The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does
Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation)))
}
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
pub fn fs_pair_loader() -> PairLoader {
@@ -660,13 +862,18 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
}
impl crate::Session {
/// Install the filesystem file hooks (desktop, CLI, MCP headless).
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
pub fn with_fs(mut self) -> Self {
let (l, p) = fs_hooks();
self.media.file_loader = Some(l);
self.media.file_probe = Some(p);
self.media.preview_loader = Some(fs_preview_loader());
self.media.denoise.loader = Some(fs_pair_loader());
#[cfg(target_os = "macos")]
{
self.apple_photos = Some(crate::apple_photos::osascript());
}
self
}
}
@@ -689,6 +896,95 @@ mod tests {
use super::*;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a
/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot
/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the
/// format and camera string agrees.
#[test]
fn white_balance_scale_follows_the_files_matrices_then_the_cameras() {
use lightcraft_raw::{ColorData, RawFormat};
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
let mul = Some([2.2f32, 1.0, 1.6]);
let d65 = lightcraft_color::D65;
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some());
let (t, tint) = a7m3.as_shot(d65);
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
assert!(bare.legacy_relative && bare.spectral_white.is_none());
assert_eq!(bare.as_shot(d65), {
let (t, tint) = xy_to_temp_tint(d65);
(t.round(), tint.round())
});
// a camera without measured matrices: relative, whatever the multipliers
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none());
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none());
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
}
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
let head = 160;
let mut f = vec![0u8; head];
f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW ");
f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes());
f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes());
f.extend_from_slice(jpeg);
f.extend(std::iter::repeat_n(0x33u8, tail));
f
}
/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a
/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten
/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the
/// quick path produces must equal the whole-file path's, exactly.
#[test]
fn preview_loading_reads_only_the_header_and_the_jpeg() {
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let file = raf_with_embedded_jpeg(&jpeg, 200_000);
let dir = crate::tests_xmp::temp_dir("preview-window");
let path = dir.join("dscf0001.raf");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
assert!(read.header_path, "the header path is what answered");
assert_eq!(read.total, file.len() as u64);
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
// The quick path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
let plain = dir.join("plain.jpg");
std::fs::write(&plain, &jpeg).unwrap();
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
/// file or one that isn't an image.
+44 -5
View File
@@ -51,11 +51,22 @@ pub fn is_cjk_script(script: &str) -> bool {
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
}
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
/// Empty without craft-fonts.
/// The craft-fonts faces to fall back on for `script`, in preference order: the CJK faces, those of
/// that script first (for `style` when asked), then the faces of every other non-Latin script
/// (Thai, Arabic, ...), which file and folder names can contain whatever the interface language.
/// One CJK face list serves every language, but Han characters are shared between them: a Chinese
/// reader must get the Chinese forms and a Japanese reader the Japanese ones, so the active
/// language's own script comes first. The other scripts share no characters, so their order only
/// prefers `style`. Empty without craft-fonts.
pub fn ui_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
let mut faces = cjk_fallback(fonts, script, style);
let mut others: Vec<&CraftFont> = fonts.iter().filter(|font| !covers_cjk(font) && font.scripts.iter().any(|s| *s != "Latn")).collect();
others.sort_by_key(|font| font.style != style);
faces.extend(others);
faces
}
/// The CJK part of [`ui_fallback`], in the same preference order.
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
faces.sort_by_key(|font| {
@@ -92,6 +103,13 @@ mod tests {
assert!(ab_glyph::FontRef::try_from_slice(f.bytes).is_ok(), "{} {} parses", f.family, f.style);
}
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
// Every script face that ships is a UI fallback, and has the glyphs of its script (#736).
use ab_glyph::Font;
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] {
let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue };
let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses");
assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family);
}
}
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
@@ -112,4 +130,25 @@ mod tests {
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
}
/// Faces for other scripts (#736: Thai folder names showed boxes) follow the CJK faces in the
/// UI fallback whatever the language; a Latin-only face is not a fallback (Inter covers Latin).
#[test]
fn other_scripts_follow_the_cjk_faces() {
static FACES: &[CraftFont] = &[
CraftFont { family: "Noto Sans Thai", style: "Regular", scripts: &["Thai", "Latn"], bytes: &[] },
CraftFont { family: "Latin Only Sans", style: "Regular", scripts: &["Latn"], bytes: &[] },
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
CraftFont { family: "Noto Sans Arabic", style: "Bold", scripts: &["Arab"], bytes: &[] },
CraftFont { family: "Noto Sans Arabic", style: "Regular", scripts: &["Arab"], bytes: &[] },
];
let families = |script: &str, style: &str| ui_fallback(FACES, script, style).iter().map(|f| (f.family, f.style)).collect::<Vec<_>>();
assert_eq!(
families("Latn", "Regular"),
[("Noto Sans CJK SC", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Arabic", "Regular"), ("Noto Sans Arabic", "Bold")]
);
assert_eq!(families("Hans", "Bold")[0], ("Noto Sans CJK SC", "Regular"));
assert_eq!(families("Hans", "Bold")[1], ("Noto Sans Arabic", "Bold"));
assert!(ui_fallback(&[], "Thai", "Regular").is_empty());
}
}
+76 -5
View File
@@ -6,6 +6,8 @@
//! 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).
//! A duplicate (by path or content) of a photo in Recently Deleted is restored instead, with
//! its edits, unless the import says otherwise ([`OnDeleted`]).
//! 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
@@ -53,12 +55,19 @@ pub enum ImportMode {
}
/// What an import does with a file the library already has in Recently Deleted.
///
/// Someone who imports such a file wants the photo in the library again, so the default brings
/// it back (issues #298, #706: before, the import skipped it as "already in the library" while
/// the grid showed it deleted, and the user was stuck). Restoring loses nothing — the edits,
/// rating and albums come back with the photo and Reset Edits gives a clean start — where
/// importing afresh would silently drop them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OnDeleted {
/// Leave it there; the report says so ([`Duplicate::existing_deleted`]).
#[default]
Skip,
/// Bring the trashed photo back, with its edits ([`ImportReport::restored`]).
/// Bring the trashed photo back, with its edits ([`ImportReport::restored`]); a photo whose
/// file is no longer where the library had it is relinked to the imported file.
#[default]
Restore,
/// Delete the trashed record permanently and import the file as a new photo.
Fresh,
@@ -78,7 +87,7 @@ impl OnDeleted {
/// What an import does besides adding the photos.
#[derive(Clone, Debug, Default)]
pub struct ImportOptions {
/// A file that is in Recently Deleted: skip (default), restore, or import afresh.
/// A file that is in Recently Deleted: restore (default), skip, or import afresh.
pub on_deleted: OnDeleted,
pub mode: ImportMode,
/// Applied to every imported photo (one History entry).
@@ -201,6 +210,48 @@ pub struct ImportReport {
pub scanned: usize,
/// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP).
pub sidecars: usize,
/// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ImportWarning>,
}
/// A structured diagnostic from foreign XMP develop settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImportWarningCode {
/// A named camera profile was found but cannot be applied as Lightroom does.
CameraProfile,
/// Lightroom geometry data was found but its saved transform is not retained.
UprightGeometry,
/// One or more other XMP develop fields have no LightCraft mapping.
UnmappedXmp,
}
/// The saved Lightroom Upright mode associated with a foreign geometry transform.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImportUprightMode {
Off,
Auto,
Level,
Vertical,
Full,
Guided,
Unknown,
}
/// One actionable XMP import diagnostic. `path` is filled when its photo is committed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportWarning {
pub path: String,
pub code: ImportWarningCode,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<ImportUprightMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
}
/// A file moved by an import.
@@ -912,7 +963,7 @@ impl ImportJob {
let packet = crate::sidecar::find_sidecar(&path, self.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some());
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
Ok(sc) => Some(sc),
Err(e) => {
@@ -1170,6 +1221,16 @@ fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> P
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
}
/// A trashed photo's file is not where the library had it (never on the web, where paths are
/// storage keys, not files).
fn trashed_file_is_gone(cat: &lightcraft_catalog::Catalog, id: PhotoId) -> bool {
!cfg!(target_arch = "wasm32")
&& match cat.photo(id).map(|p| &p.source) {
Some(Source::File { path }) => !Path::new(path).exists(),
_ => false,
}
}
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
/// taken back.
@@ -1210,9 +1271,15 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
});
let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted));
match trashed {
Some(id) if opts.on_deleted == OnDeleted::Restore => {
// (browsing a folder in Local only looks: it never restores)
Some(id) if opts.on_deleted == OnDeleted::Restore && !opts.local => {
if !report.restored.contains(&id.0) {
ops.push(Op::SetDeleted { id, deleted: false });
// matched by content from another path, and the file the record
// points at is gone (moved, renamed): the photo follows the file
if reason == "content" && trashed_file_is_gone(&s.catalog, id) {
ops.push(crate::cmd::missing::relink_op(id, &path));
}
report.restored.push(id.0);
}
}
@@ -1249,6 +1316,10 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| {
warning.path = path.clone();
warning
}));
}
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);
+160
View File
@@ -0,0 +1,160 @@
//! Lens profile corrections from a lens database the user supplies ([`lightcraft_lensdb`]).
//!
//! When a raw carries no lens correction of its own (DNG opcodes, or a camera correction a raw reader converts,
//! [`crate::files::embedded_lens`]), the lens named in its EXIF is looked up in a lensfun-format database: the
//! directory in `LIGHTCRAFT_LENSFUN_DIR` (e.g. lensfun's `data/db`, CC-BY-SA, which is never bundled with the app).
//! The profile is converted to the same [`lightcraft_develop::EmbeddedLens`] form, so "Enable Profile Corrections", its
//! amount sliders, the GPU path and DNG export treat both alike. Without the variable nothing changes.
use std::path::PathBuf;
use std::sync::OnceLock;
use lightcraft_develop::EmbeddedLens;
use lightcraft_lensdb::{LensDb, Shot};
/// The environment variable naming the lens database directory.
pub const ENV: &str = "LIGHTCRAFT_LENSFUN_DIR";
/// The configured database, read once per process; `None` when none is configured or it holds no lenses.
pub fn database() -> Option<&'static LensDb> {
static DB: OnceLock<Option<LensDb>> = OnceLock::new();
DB.get_or_init(|| {
let dir = PathBuf::from(std::env::var_os(ENV)?);
match LensDb::load_dir(&dir) {
Ok((db, errors)) => {
for (file, e) in errors {
log::warn!("lens database: skipped {file}: {e}");
}
(!db.lenses.is_empty()).then_some(db)
}
Err(e) => {
log::warn!("{e}");
None
}
}
})
.as_ref()
}
/// The database profile for a photo with metadata `m`, for its `w × h` (unoriented, default-cropped) image,
/// re-expressed after `orientation`. `None` without a database, lens name, focal length or matching profile.
pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option<EmbeddedLens> {
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
let crop =
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
let corr = lens.correction(&shot)?;
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
}
/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`.
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
crate::files::embedded_lens(raw).or_else(|| {
let db = database()?;
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) };
lookup(db, &meta(), w as f64, h as f64, raw.orientation)
})
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_geom::Orientation;
const DB: &str = r#"<lensdatabase>
<camera><maker>Testcam</maker><model>TC-1</model><mount>T</mount><cropfactor>1.5</cropfactor></camera>
<lens><maker>Testcam</maker><model>Testcam 18-55mm f/3.5-5.6</model><mount>T</mount><cropfactor>1.5</cropfactor>
<calibration>
<distortion model="poly3" focal="18" k1="-0.03" />
<vignetting model="pa" focal="18" aperture="3.5" distance="1000" k1="-0.4" k2="0" k3="0" />
</calibration>
</lens></lensdatabase>"#;
fn meta() -> lightcraft_meta::Metadata {
lightcraft_meta::Metadata {
make: Some("Testcam".into()),
model: Some("TC-1".into()),
lens_model: Some("18-55mm F3.5-5.6".into()),
focal_length: Some(18.0),
f_number: Some(3.5),
..Default::default()
}
}
#[test]
fn exif_lens_is_found_and_converted() {
let db = LensDb::parse(DB).unwrap();
let lens = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Normal).unwrap();
assert!(lens.warp.is_some() && lens.vignette.is_some());
let rotated = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Rotate90).unwrap();
assert_eq!(rotated.warp.unwrap().center, lightcraft_geom::Point::new(0.5, 0.5));
// no lens name, no focal length, an unknown lens: nothing
assert!(lookup(&db, &lightcraft_meta::Metadata { lens_model: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none());
assert!(lookup(&db, &lightcraft_meta::Metadata { focal_length: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none());
let other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..meta() };
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
}
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
/// stage with straight lines.
#[test]
fn pipeline_straightens_a_distorted_grid() {
let db = LensDb::parse(DB).unwrap();
let profile = &db.lenses[0];
let (w, h) = (600usize, 400usize);
let lens = lookup(&db, &meta(), w as f64, h as f64, Orientation::Normal).unwrap();
// record the grid: each sensor pixel sees the scene point whose distorted position it is
let n = h as f64 / 2.0;
let undistort = |x: f64, y: f64| {
let (dx, dy) = ((x - w as f64 / 2.0) / n, (y - h as f64 / 2.0) / n);
let rd = dx.hypot(dy);
let (mut lo, mut hi) = (0.0, 3.0);
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
if mid * profile.distortion_ratio(18.0, mid) < rd {
lo = mid;
} else {
hi = mid;
}
}
let s = if rd > 0.0 { lo / rd } else { 1.0 };
(w as f64 / 2.0 + dx * s * n, h as f64 / 2.0 + dy * s * n)
};
// horizontal scene lines every 50 px (2 px wide, dark on light)
let line = |v: f64| if v.rem_euclid(50.0) < 2.0 { 0.02f32 } else { 0.8 };
let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| {
let (_, sy) = undistort(x as f64 + 0.5, y as f64 + 0.5);
[line(sy); 3]
});
// the dark row nearest y = 51 in each column, sub-pixel (centroid of darkness), as rendered
let line_rows = |img: &lightcraft_raster::Rgba8| -> Vec<f64> {
(20..w - 20)
.step_by(10)
.map(|x| {
let (mut sw, mut swy) = (0.0, 0.0);
for y in 35..70 {
let d = 255.0 - img.get(x, y)[1] as f64;
if d > 60.0 {
sw += d;
swy += d * (y as f64 + 0.5);
}
}
swy / sw.max(1e-9)
})
.collect()
};
let spread = |rows: &[f64]| rows.iter().cloned().fold(f64::MIN, f64::max) - rows.iter().cloned().fold(f64::MAX, f64::min);
let req = lightcraft_pipeline::RenderRequest::fit(w, h);
let mut s = lightcraft_develop::DevelopSettings::default();
let info = lightcraft_pipeline::SourceInfo { lens: Some(lens), ..Default::default() };
let before = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image));
s.optics.lens_profile = true;
let after = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image));
assert!(before > 2.0, "the recorded line is curved: {before} px");
// 8-bit output and a 2 px line: the centroid measurement itself is good to about a pixel
assert!(after < 1.0 && after < before / 8.0, "corrected line bent by {after} px (uncorrected {before})");
}
}
+146 -27
View File
@@ -15,6 +15,8 @@
extern crate lightcraft_denoise_core as lightcraft_denoise;
pub mod activity;
#[cfg(not(target_arch = "wasm32"))]
pub mod apple_photos;
pub mod availability;
pub(crate) mod camera_preview;
pub mod camera_profiles;
@@ -38,9 +40,11 @@ pub mod fonts;
pub mod guard;
pub mod import;
mod import_move;
pub mod lens_profiles;
pub mod library;
mod lightroom_archive;
pub mod lightroom_catalog;
pub mod lightroom_folder;
pub mod lightroom_job;
mod lightroom_sqlite;
pub mod logging;
@@ -52,6 +56,7 @@ pub mod originals;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod proof;
pub mod rename;
pub mod segment;
pub mod sidecar;
@@ -88,6 +93,11 @@ pub enum EngineError {
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
#[error("{0}")]
LibraryInUse(String),
/// The folder (or file) asked for is a Lightroom Classic catalog or part of one, not a
/// LightCraft library (issue #891): nothing was created or opened. `catalog`: the `.lrcat`
/// to import with `library.importLightroom`, when one was found.
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
#[error("{0}")]
Other(String),
}
@@ -100,8 +110,13 @@ pub struct UndoEntry {
pub label: String,
pub op: Op,
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists.
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An
/// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder).
pub folder: Option<FolderMove>,
/// Inverses of logged path corrections from a partially failed history step.
pub(crate) retry_inverse: Vec<Op>,
/// Sidecars still separated from their photo after a failed move or rollback.
pub(crate) retry_sidecars: Vec<rename::SidecarMove>,
}
/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag,
@@ -174,6 +189,8 @@ pub struct Session {
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
commits: u64,
pub redo: Vec<UndoEntry>,
/// Exact filesystem repairs from discarded history; catalog inverses stay discarded.
pub(crate) pending_sidecar_repairs: Vec<rename::SidecarMove>,
pub interaction: Option<Interaction>,
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
@@ -184,6 +201,8 @@ pub struct Session {
pub face_models_dir: Option<std::path::PathBuf>,
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.downloads`).
pub face_downloads: face_download::Downloads,
/// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`).
pub place_names: cmd::geocode::PlaceNames,
/// The user's own list of models to download (`catalog.json` in the models folder), as last read.
pub(crate) face_catalog: lightcraft_faces::catalog::Catalog,
/// The loaded recognition model and the face embeddings made with it.
@@ -276,6 +295,20 @@ pub struct Session {
/// 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,
/// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by
/// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests
/// put a fake here.
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos: Option<apple_photos::Runner>,
/// This session's Apple Photos imports, running or finished (`export.photosImports`).
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos_imports: apple_photos::Imports,
/// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`,
/// `app.export` without `background`): set by the desktop app, which answers control requests
/// on its UI thread. Off (the default), they wait for Photos, so a one-shot process
/// (lightcraft-cli, a script) never ends in the middle of one.
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos_background: bool,
}
impl Default for Session {
@@ -305,11 +338,13 @@ impl Session {
undo: Vec::new(),
commits: 0,
redo: Vec::new(),
pending_sidecar_repairs: Vec::new(),
interaction: None,
skip_auto_write: false,
denoise: Default::default(),
face_models_dir: None,
face_downloads: Default::default(),
place_names: Default::default(),
face_catalog: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
faces: Default::default(),
@@ -355,6 +390,12 @@ impl Session {
cache_mb: 0,
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
apple_photos: None,
#[cfg(not(target_arch = "wasm32"))]
apple_photos_imports: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
apple_photos_background: false,
}
}
@@ -409,7 +450,7 @@ impl Session {
if self.depth == 0 {
let skip = std::mem::take(&mut self.skip_auto_write);
if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars(&self.pending_log[log_start..]);
self.auto_write_sidecars_since(log_start);
}
}
if self.depth == 0 && self.library.is_some() {
@@ -438,17 +479,34 @@ impl Session {
/// Apply an op as one undoable step.
pub fn commit(&mut self, label: &str, op: Op) -> Result<()> {
let fwd = op.clone();
let inv = self.catalog.apply(op)?;
let inv = self.apply_catalog(op)?;
self.pending_log.push(fwd);
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new() });
self.commits = self.commits.wrapping_add(1);
if self.undo.len() > 1000 {
self.undo.remove(0);
let mut dropped = self.undo.remove(0);
self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars);
}
for mut dropped in self.redo.drain(..) {
self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars);
}
self.redo.clear();
Ok(())
}
fn apply_catalog(&mut self, op: Op) -> Result<Op> {
let inv = self.catalog.apply(op.clone())?;
let mut pending = vec![&op];
while let Some(op) = pending.pop() {
match op {
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id),
Op::AddPhoto { photo } => self.media.forget(photo.id),
Op::Batch { ops } => pending.extend(ops),
_ => {}
}
}
Ok(inv)
}
/// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo
/// step renames it back (`folder` is the undo direction: current place → old place).
pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> {
@@ -471,8 +529,8 @@ impl Session {
}
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
/// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone.
#[cfg(not(target_arch = "wasm32"))]
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
/// redo stack alone.
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
let fwd = op.clone();
self.catalog.apply(op)?;
@@ -516,13 +574,20 @@ impl Session {
if n < 2 || n > self.undo.len() {
return;
}
// a step that moves a folder on disk stays on its own
if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) {
let Some(entries) = self.undo.get(self.undo.len() - n..) else { return };
// Folder moves and partially applied file steps stay on their own.
if entries.iter().any(|e| e.folder.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) {
return;
}
let tail = self.undo.split_off(self.undo.len() - n);
let ops = tail.into_iter().rev().map(|e| e.op).collect();
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None });
self.undo.push(UndoEntry {
label: label.to_string(),
op: Op::Batch { ops },
folder: None,
retry_inverse: Vec::new(),
retry_sidecars: Vec::new(),
});
}
/// Apply without recording undo (interactive previews).
@@ -532,8 +597,12 @@ impl Session {
}
pub fn undo_step(&mut self) -> Result<String> {
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
self.undo_step_with(&rename::RealFs)
}
pub(crate) fn undo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
let mut e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let redo = match self.apply_with_files(&mut e, fs) {
Ok(r) => r,
Err(err) => {
self.undo.push(e);
@@ -542,13 +611,23 @@ impl Session {
};
self.show_undone(&e.op);
self.pending_log.push(e.op);
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
self.redo.push(UndoEntry {
label: e.label.clone(),
op: redo,
folder: e.folder.as_ref().map(FolderMove::reversed),
retry_inverse: Vec::new(),
retry_sidecars: Vec::new(),
});
Ok(e.label)
}
pub fn redo_step(&mut self) -> Result<String> {
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
self.redo_step_with(&rename::RealFs)
}
pub(crate) fn redo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
let mut e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let undo = match self.apply_with_files(&mut e, fs) {
Ok(r) => r,
Err(err) => {
self.redo.push(e);
@@ -557,7 +636,13 @@ impl Session {
};
self.show_undone(&e.op);
self.pending_log.push(e.op);
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
self.undo.push(UndoEntry {
label: e.label.clone(),
op: undo,
folder: e.folder.as_ref().map(FolderMove::reversed),
retry_inverse: Vec::new(),
retry_sidecars: Vec::new(),
});
Ok(e.label)
}
@@ -583,37 +668,63 @@ impl Session {
/// 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; the
/// folder is only moved back when no file was left behind at its new path.
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
let fs = rename::RealFs;
fn apply_with_files(&mut self, entry: &mut UndoEntry, fs: &dyn rename::MoveFs) -> Result<Op> {
// The entry is detached from self.undo/self.redo: include it before any global
// repair can retire, then retry revalidates it before moving its own packets.
rename::validate_sidecars(fs, &entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
// Repair discarded file steps before another path change can stale their exact paths.
// Unrelated metadata history must remain usable while a sidecar is still unwritable.
if entry.folder.is_some() || !entry.retry_sidecars.is_empty() || !self.file_moves(&entry.op).is_empty() {
self.repair_sidecars_with(fs).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
}
rename::retry_sidecars(fs, &mut entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
let op = &entry.op;
let folder = entry.folder.as_ref();
if let Some(f) = folder {
cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
}
let undo_folder = || {
if let Some(f) = folder {
let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from);
let _ = cmd::browse::folder_on_disk(&f.to, &f.from);
}
};
let moves = self.file_moves(op);
if let Err(e) = rename::move_all(&fs, &moves) {
if let Err(mut e) = rename::move_all(fs, &moves) {
entry.retry_sidecars.append(&mut e.pending_sidecars);
if e.stuck.is_empty() {
undo_folder();
}
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
let (message, mut inverses) = self.follow_stuck(op, e, false);
// A later successful retry observes the corrected paths. Retain their old paths
// now, so the opposite history direction still restores the entire original step.
inverses.append(&mut entry.retry_inverse);
entry.retry_inverse = inverses;
return Err(EngineError::Other(format!("can't move the files back: {message}")));
}
match self.catalog.apply(op.clone()) {
match self.apply_catalog(op.clone()) {
Ok(inv) => {
if let Some(f) = folder {
cmd::browse::follow_folder(self, &f.from, &f.to);
}
Ok(inv)
if entry.retry_inverse.is_empty() {
Ok(inv)
} else {
let mut ops = match inv {
Op::Batch { ops } => ops,
op => vec![op],
};
ops.append(&mut entry.retry_inverse);
Ok(Op::Batch { ops })
}
}
Err(e) => {
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
if let Err(be) = rename::move_all(&fs, &back) {
if let Err(mut be) = rename::move_all(fs, &back) {
entry.retry_sidecars.append(&mut be.pending_sidecars);
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
}
undo_folder();
Err(e.into())
Err(e)
}
}
}
@@ -867,8 +978,12 @@ mod tests;
#[cfg(test)]
mod tests_album_order;
#[cfg(test)]
mod tests_albums_local;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_crop_angle;
#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;
@@ -878,6 +993,8 @@ mod tests_face_models;
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_recognize;
#[cfg(test)]
mod tests_filesystem_history;
#[cfg(test)]
mod tests_folders;
#[cfg(test)]
mod tests_forget_local;
@@ -892,6 +1009,8 @@ mod tests_libops;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_lightroom_folder;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_organize;
+124
View File
@@ -32,6 +32,32 @@ use crate::{EngineError, LibrarySource, Result, Selection, Session};
/// Library directory name inside the user's Pictures folder.
pub const DEFAULT_NAME: &str = "LightCraft Library";
/// The library folder `path` names. A library is a folder; naming one of its own files
/// (`catalog.snap`, `catalog.log`, `catalog.lock`) means the folder that holds it. Any other
/// existing file is an error that says so, instead of the OS's "File exists" from creating the
/// folder.
pub fn resolve_dir(path: &Path) -> Result<PathBuf> {
if !path.is_file() {
return Ok(path.to_path_buf());
}
let own = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG, lightcraft_catalog::lock::LOCK].contains(&n));
if !own {
return Err(EngineError::Other(format!("{} is a file; a library is a folder", path.display())));
}
Ok(match path.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(dir) => dir.to_path_buf(),
None => PathBuf::from("."),
})
}
/// Whether `dir` already holds a library (its catalog files).
pub fn is_library(dir: &Path) -> bool {
dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file()
}
/// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library`
/// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows).
pub fn default_dir() -> Option<PathBuf> {
@@ -246,14 +272,49 @@ impl SettingsLoad {
}
impl Session {
/// Check, without closing the library open now, that [`Session::open_library`] can be expected
/// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not
/// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]).
/// Nothing is created; a library folder that exists gets its lock taken and released again.
/// Opening can still fail afterwards (a damaged catalog, a disk going away).
pub fn check_library(&self, dir: impl AsRef<Path>) -> Result<PathBuf> {
let dir = dir.as_ref().to_path_buf();
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
}
let dir = resolve_dir(&dir)?;
if !dir.exists() {
return Ok(dir);
}
if !dir.is_dir() {
return Err(EngineError::Other(format!("{} is not a folder", dir.display())));
}
std::fs::read_dir(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())))?;
// the library open in this session keeps its lock when reopened; any other must be free
let current = self.library.as_ref().is_some_and(|l| l.on_disk && same_dir(&l.dir, &dir));
if !current && is_library(&dir) {
drop(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?);
}
Ok(dir)
}
/// 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.
///
/// A Lightroom Classic catalog folder, one of its `.lrdata` / `.lrcat-data` folders or an
/// `.lrcat` file is refused with [`EngineError::LightroomCatalog`] before anything is
/// created ([`crate::lightroom_folder`]); a LightCraft library always opens, whatever else
/// its folder holds.
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
let dir = dir.as_ref().to_path_buf();
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
}
let dir = resolve_dir(&dir)?;
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() };
// the library open in this session (same directory): hand its lock over
@@ -312,6 +373,9 @@ impl Session {
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
self.catalog = catalog;
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
self.pending_sidecar_repairs.append(&mut entry.retry_sidecars);
}
self.undo.clear();
self.redo.clear();
self.interaction = None;
@@ -655,3 +719,63 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
.to_string()
})
}
#[cfg(test)]
mod resolve_tests {
use super::*;
/// `--library <dir>/catalog.snap` failed with the OS's "File exists" (creating a folder over
/// the file); it opens the library that file belongs to.
#[test]
fn a_librarys_own_file_opens_its_folder_and_any_other_file_is_a_clear_error() {
let dir = std::env::temp_dir().join(format!("lc-resolve-lib-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&dir, false).unwrap();
s.close_library().unwrap();
drop(s);
assert!(is_library(&dir));
let snap = dir.join(lightcraft_catalog::journal::SNAPSHOT);
assert_eq!(resolve_dir(&snap).unwrap(), dir);
let mut s = Session::new().with_fs();
s.open_library(&snap, false).unwrap();
assert_eq!(s.library.as_ref().map(|l| l.dir.clone()), Some(dir.clone()));
drop(s);
let other = dir.join("notes.txt");
std::fs::write(&other, b"x").unwrap();
let e = Session::new().with_fs().open_library(&other, false).err().unwrap().to_string();
assert!(e.contains("is a file"), "{e}");
assert!(!e.contains("File exists"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// `check_library` refuses what `open_library` would, without touching the open library.
#[test]
fn check_library_refuses_locked_libraries_files_and_lightroom_catalogs() {
let dir = std::env::temp_dir().join(format!("lc-check-lib-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (a, b) = (dir.join("a"), dir.join("b"));
std::fs::create_dir_all(&b).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&b, false).unwrap();
s.close_library().unwrap();
s.open_library(&a, false).unwrap();
assert_eq!(s.check_library(&a).unwrap(), a, "the open library keeps its lock");
assert_eq!(s.check_library(&b).unwrap(), b);
assert_eq!(s.check_library(dir.join("new")).unwrap(), dir.join("new"));
assert!(!dir.join("new").exists(), "nothing created");
let held = LibraryLock::acquire(&b, "other").unwrap();
assert!(matches!(s.check_library(&b), Err(EngineError::LibraryInUse(_))));
drop(held);
assert!(s.check_library(&b).is_ok(), "the probe released its lock");
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
assert!(s.check_library(dir.join("notes.txt")).is_err());
std::fs::write(dir.join("Cat.lrcat"), b"x").unwrap();
assert!(matches!(s.check_library(dir.join("Cat.lrcat")), Err(EngineError::LightroomCatalog { .. })));
assert!(s.library.as_ref().is_some_and(|l| l.dir == a));
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
}
+86 -15
View File
@@ -2,7 +2,7 @@
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
@@ -336,6 +336,29 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
Ok(committed)
}
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
/// catalog's volume, resolved against the catalog's folder, if that is a folder.
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
return None;
}
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
let mut out = PathBuf::new();
for part in dir.components() {
match part {
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::CurDir => {}
part => out.push(part),
}
}
out.is_dir().then_some(out)
}
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
pub fn read(path: &Path) -> Result<CatalogImport, String> {
let cancel = AtomicBool::new(false);
@@ -428,6 +451,21 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
}
out
};
let mut root_paths = HashMap::new();
let mut root_ids: Vec<_> = roots.keys().copied().collect();
root_ids.sort_unstable();
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
let absolute = text(root, "absolutePath");
let resolved = match relocated_root(absolute, text(root, "relativePathFromCatalog"), path) {
Some(found) => {
let found = found.to_string_lossy().to_string();
warnings.push(format!("root folder {}: {absolute} not found; using {found} (relative to the catalog)", text(root, "name")));
found
}
None => absolute.to_string(),
};
root_paths.insert(id, resolved);
}
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?);
let mut out = CatalogImport {
@@ -444,7 +482,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
let root_path = text(root, "absolutePath");
let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
let folder_path = text(folder, "pathFromRoot");
let name = text(file, "idx_filename");
if root_path.is_empty() || name.is_empty() {
@@ -1259,19 +1297,9 @@ mod tests {
}
page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes());
}
#[test]
fn reads_native_sqlite_tables_without_lightroom_or_external_processes() {
use SqlValue::{Integer as I, Null as N, Text as T};
let tables = [
("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]),
("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]),
("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]),
(
"Adobe_images",
"id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick",
vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)],
),
];
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
use SqlValue::{Integer as I, Text as T};
let mut bytes = vec![0u8; 5 * 2048];
bytes[..16].copy_from_slice(b"SQLite format 3\0");
bytes[16..18].copy_from_slice(&2048u16.to_be_bytes());
@@ -1295,6 +1323,30 @@ mod tests {
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
}
leaf(&mut bytes[..2048], 100, &schema);
bytes
}
fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec<SqlValue>)> {
use SqlValue::{Integer as I, Null as N, Text as T};
vec![
(
"AgLibraryRootFolder",
"id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog",
vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())],
),
("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]),
("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]),
(
"Adobe_images",
"id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick",
vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)],
),
]
}
#[test]
fn reads_native_sqlite_tables_without_lightroom_or_external_processes() {
let bytes = native_catalog(&photo_tables(("/catalog/", "")));
let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id()));
std::fs::write(&path, &bytes).unwrap();
let catalog = read(&path).unwrap();
@@ -1307,6 +1359,25 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[test]
fn a_root_missing_here_is_found_relative_to_the_catalog() {
let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Lightroom")).unwrap();
std::fs::create_dir_all(dir.join("RAW")).unwrap();
let path = dir.join("Lightroom").join("Lightroom.lrcat");
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap();
let catalog = read(&path).unwrap();
assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW"));
assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings);
// a relative path that leads nowhere keeps the recorded one
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
let catalog = read(&path).unwrap();
assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn oversized_optional_table_warns_and_is_skipped() {
let mut main = vec![0u8; 2 * 512];
+138
View File
@@ -0,0 +1,138 @@
//! Recognise a Lightroom Classic catalog where a LightCraft library was asked for (issue #891).
//!
//! A Lightroom catalog folder holds `Name.lrcat` (SQLite) next to `Name Previews.lrdata`,
//! `Name Smart Previews.lrdata`, `Name Helper.lrdata`, `Name.lrcat-data`, the `-wal` / `-shm` /
//! `.lock` files of an open catalog and `Backups/`. Opening any of these as a library would create
//! an empty LightCraft library inside Lightroom's folder; [`Session::open_library`] refuses with
//! [`EngineError::LightroomCatalog`] instead, and the app offers File ▸ Import Lightroom Catalog….
//!
//! What counts (only names are looked at; no catalog is opened or read):
//! - a LightCraft library (`catalog.snap` or `catalog.log` in the folder) is never a Lightroom
//! catalog, whatever else the folder holds: it opens as before;
//! - a path named `*.lrcat` or one of its companions (`*.lrcat-wal`, `*.lrcat-shm`,
//! `*.lrcat-journal`, `*.lrcat.lock`);
//! - a folder named `*.lrdata` or `*.lrcat-data` (Lightroom's previews and data);
//! - a folder holding a `*.lrcat` at its top level (the catalog folder itself), or `Backups/`
//! next to one.
//!
//! The folder scan is bounded ([`SCAN_LIMIT`] entries, top level only) and every I/O error just
//! means "not found": this never fails and never panics.
//!
//! [`Session::open_library`]: crate::Session::open_library
//! [`EngineError::LightroomCatalog`]: crate::EngineError::LightroomCatalog
use std::path::{Path, PathBuf};
/// The most directory entries looked at in one folder.
pub const SCAN_LIMIT: usize = 10_000;
/// A Lightroom Classic catalog (or part of one) found where a library was asked for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LightroomPlace {
/// The `.lrcat` file to import, when one was found.
pub catalog: Option<PathBuf>,
}
/// Companion files of an open catalog, after the `.lrcat` (lower case).
const COMPANIONS: [&str; 4] = ["-wal", "-shm", "-journal", ".lock"];
/// Lightroom's own `.lrdata` folders: `Name<suffix>.lrdata` belongs to `Name.lrcat`.
const LRDATA_SUFFIXES: [&str; 4] = [" Smart Previews", " Previews", " Helper", " Sync"];
/// `path`'s file name, lower-cased (lossy: only used to compare with ASCII suffixes).
fn lower_name(path: &Path) -> Option<String> {
path.file_name().map(|n| n.to_string_lossy().to_lowercase())
}
/// `name` ends with `suffix`, ignoring ASCII case; returns the part before it (`None` for a
/// non-UTF-8 name, or when the cut would not be on a character boundary).
fn strip_suffix_nocase<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
let cut = name.len().checked_sub(suffix.len())?;
let tail = name.get(cut..)?;
tail.eq_ignore_ascii_case(suffix).then(|| name.get(..cut)).flatten()
}
/// `name` is `*.lrcat` (any case).
fn is_lrcat_name(name: &str) -> bool {
name.to_lowercase().ends_with(".lrcat")
}
/// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than
/// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped.
pub fn find_lrcat(dir: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
let mut found: Option<PathBuf> = None;
for entry in entries.take(SCAN_LIMIT).flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !is_lrcat_name(name) {
continue;
}
let path = entry.path();
// (follows a symlink; a folder named `*.lrcat` is not a catalog)
if !path.is_file() {
continue;
}
if found.as_ref().is_none_or(|f| path < *f) {
found = Some(path);
}
}
found
}
/// `candidate` if it is a file, else the first catalog in `dir`.
fn catalog_or_scan(candidate: Option<PathBuf>, dir: Option<&Path>) -> Option<PathBuf> {
candidate.filter(|c| c.is_file()).or_else(|| dir.and_then(find_lrcat))
}
/// Whether `path`, given as a library folder, is a Lightroom Classic catalog or part of one (see
/// the module docs). `None`: not Lightroom's, open it as a library.
pub fn lightroom_catalog_at(path: &Path) -> Option<LightroomPlace> {
let is_dir = path.is_dir();
if is_dir && crate::library::is_library(path) {
return None;
}
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let name = path.file_name().and_then(|n| n.to_str());
let lower = lower_name(path).unwrap_or_default();
// the catalog file itself (a folder named `*.lrcat` is looked into below)
if lower.ends_with(".lrcat") && !is_dir {
return Some(LightroomPlace { catalog: path.is_file().then(|| path.to_path_buf()) });
}
// an open catalog's companion files: `Name.lrcat-wal` → `Name.lrcat`
for c in COMPANIONS {
if lower.ends_with(&format!(".lrcat{c}")) && !is_dir {
let candidate = name.and_then(|n| strip_suffix_nocase(n, c)).and_then(|n| parent.map(|p| p.join(n)));
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
}
}
// `Name.lrcat-data` → `Name.lrcat`
if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, "-data"))
&& is_lrcat_name(stem)
{
let candidate = parent.map(|p| p.join(stem));
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
}
// `Name Previews.lrdata` (and Smart Previews, Helper, Sync) → `Name.lrcat`
if let Some(stem) = name.and_then(|n| strip_suffix_nocase(n, ".lrdata")) {
let base = LRDATA_SUFFIXES.iter().find_map(|s| strip_suffix_nocase(stem, s)).unwrap_or(stem);
let candidate = parent.map(|p| p.join(format!("{base}.lrcat")));
return Some(LightroomPlace { catalog: catalog_or_scan(candidate, parent) });
}
if lower.ends_with(".lrdata") || lower.ends_with(".lrcat-data") {
// (a non-UTF-8 name)
return Some(LightroomPlace { catalog: parent.and_then(find_lrcat) });
}
if is_dir {
// the catalog folder
if let Some(catalog) = find_lrcat(path) {
return Some(LightroomPlace { catalog: Some(catalog) });
}
// `Backups/` next to a catalog
if lower == "backups"
&& let Some(catalog) = parent.and_then(find_lrcat)
{
return Some(LightroomPlace { catalog: Some(catalog) });
}
}
None
}
+3 -2
View File
@@ -141,7 +141,8 @@ pub struct LightroomJob {
impl LightroomJob {
/// Snapshot session state only. This constructor performs no filesystem work.
pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result<Self> {
let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
// a photo deleted here stays deleted: the catalog's record is matched, not restored
let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
let import = ImportJob::new(s, opts)?;
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
@@ -242,7 +243,7 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom)
let undo0 = s.undo.len();
prepared.normal.revalidate_add(s);
let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
let options = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
let now = prepared.now.clone();
let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?;
let applied = lightroom_catalog::apply_prepared(
+292 -28
View File
@@ -19,7 +19,7 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Weak};
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 42;
pub const RENDER_CACHE_VERSION: u64 = 61;
/// 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];
@@ -309,8 +309,11 @@ pub struct MediaCache {
thumbs: Lru<PhotoId, DecodedSource>,
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
previews: Vec<(PhotoId, DecodedSource, u64)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, DecodedSource, u64)>,
/// The last full-resolution originals (exports, zoomed views; ~300 MB each at 24 MP), with
/// their last use. Normally one; see [`Self::set_full_capacity`].
full: Vec<(PhotoId, DecodedSource, u64)>,
/// How many full-resolution originals to keep (LRU, at least one).
full_capacity: usize,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -386,7 +389,8 @@ impl Default for MediaCache {
settings_hashes: SettingsHashes::default(),
thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)),
previews: Vec::new(),
full: None,
full: Vec::new(),
full_capacity: 1,
preview_capacity: 0,
budget,
file_loader: None,
@@ -431,7 +435,23 @@ impl MediaCache {
self.settings_hashes = SettingsHashes::default();
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
self.previews.clear();
self.full = None;
self.full.clear();
}
/// How many full-resolution originals to keep at once (at least one). Compare and Reference
/// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window
/// render of one side would evict the other side's original and decode its own again. Lowering
/// it drops the least recently used originals at once.
pub fn set_full_capacity(&mut self, n: usize) {
self.full_capacity = n.max(1);
self.trim_full();
}
fn trim_full(&mut self) {
while self.full.len() > self.full_capacity {
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
self.full.remove(oldest);
}
}
/// Bytes the caches may hold together.
@@ -463,14 +483,14 @@ impl MediaCache {
let candidates = [
self.thumbs.oldest_tick().map(|t| (t, 0)),
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
self.full.as_ref().map(|e| (e.2, 2)),
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
self.rendered.oldest_tick().map(|t| (t, 3)),
];
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
let freed = match which {
0 => self.thumbs.pop_oldest(),
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()),
2 => self.full.take().map(|e| e.1.bytes()),
2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()),
_ => self.rendered.evict_oldest(),
};
match freed {
@@ -491,7 +511,7 @@ impl MediaCache {
e.2 = lightcraft_preview::next_tick();
e.1.clone()
}),
SourceLevel::Full => self.full.as_mut().filter(|e| e.0 == id).map(|e| {
SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| {
e.2 = lightcraft_preview::next_tick();
e.1.clone()
}),
@@ -534,7 +554,11 @@ impl MediaCache {
self.previews.remove(oldest);
}
}
SourceLevel::Full => self.full = Some((id, img, tick)),
SourceLevel::Full => {
self.full.retain(|e| e.0 != id);
self.full.push((id, img, tick));
self.trim_full();
}
}
self.enforce_budget();
}
@@ -543,9 +567,7 @@ impl MediaCache {
pub fn forget(&mut self, id: PhotoId) {
self.thumbs.remove(&id);
self.previews.retain(|e| e.0 != id);
if self.full.as_ref().is_some_and(|e| e.0 == id) {
self.full = None;
}
self.full.retain(|e| e.0 != id);
}
/// (decoded thumbnail sources, bytes).
@@ -558,7 +580,7 @@ impl MediaCache {
match level {
SourceLevel::Thumb => self.thumbs.contains(&id),
SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id),
SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id),
SourceLevel::Full => self.full.iter().any(|e| e.0 == id),
}
}
@@ -566,7 +588,7 @@ impl MediaCache {
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
use crate::memory::Usage;
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum());
let full = self.full.as_ref().map(|e| Usage::new(1, e.1.bytes())).unwrap_or_default();
let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum());
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
}
@@ -613,7 +635,9 @@ impl MediaCache {
// be offline is tried first and its smart preview is the render worker's fallback.
#[cfg(not(target_arch = "wasm32"))]
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
let sp = dir.join(crate::smart::file_name(p));
let sharded = crate::smart::sharded_file_path(dir, p);
let legacy = dir.join(crate::smart::file_name(p));
let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy };
// (a proxy cut short fails to load, like the offline original; the worker's fallback
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
@@ -685,6 +709,9 @@ pub struct RenderResult {
pub level: SourceLevel,
pub key: u64,
pub rendered: Result<Rendered, String>,
/// Set when this job's render was tried on the GPU and fell back to the CPU: why
/// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way.
pub gpu_fallback: Option<String>,
/// A source that was loaded by this job (to be inserted into the cache).
pub loaded: Option<DecodedSource>,
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
@@ -733,6 +760,28 @@ impl RenderJob {
self
}
/// Render with some colour grading wheels (shadows, midtones, highlights) switched off for
/// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result
/// gets its own key and is never cached as the photo's preview or thumbnail.
pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self {
if !hidden.iter().any(|h| *h) {
return self;
}
let mut s = (*self.settings).clone();
let g = &mut s.grading;
for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) {
if off {
*wheel = lightcraft_develop::Wheel::default();
}
}
let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i));
self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93);
self.settings = Arc::new(s);
self.cache = None;
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);
@@ -767,6 +816,7 @@ impl RenderJob {
level: self.level,
key: self.key,
rendered: Ok(Rendered { image, histogram, deep: None }),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
@@ -788,7 +838,7 @@ impl RenderJob {
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
let gpu = self.cache.is_none();
let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
// for a display profile: the image goes to the display's values, the histogram
// and the cached view preview stay sRGB
let mut srgb = None;
@@ -796,7 +846,8 @@ impl RenderJob {
(Some(d), Some(_)) => {
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
if let Some(h) = h {
// (an HDR edit's HDR histogram stays)
if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) {
rendered.histogram = h;
}
srgb = s;
@@ -820,6 +871,7 @@ impl RenderJob {
level: self.level,
key: self.key,
rendered: Ok(rendered),
gpu_fallback,
loaded: (!was_loaded).then_some(source),
quick: None,
display,
@@ -832,6 +884,7 @@ impl RenderJob {
level: self.level,
key: self.key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
@@ -844,19 +897,55 @@ 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 {
develop_reporting(src, info, s, req, stages, gpu).0
}
/// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`:
/// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the
/// process-wide [`lightcraft_gpu::last_fallback`].
pub fn develop_reporting(
src: &Arc<Rgb32f>,
info: &SourceInfo,
s: &DevelopSettings,
req: &RenderRequest,
stages: Option<&StageCache>,
gpu: bool,
) -> (Rendered, Option<String>) {
let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu);
// an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead
if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) {
rendered.histogram = h;
}
(rendered, fallback)
}
fn develop_image(
src: &Arc<Rgb32f>,
info: &SourceInfo,
s: &DevelopSettings,
req: &RenderRequest,
stages: Option<&StageCache>,
gpu: bool,
) -> (Rendered, Option<String>) {
// LUT profiles have no GPU stage: they render on the CPU
if gpu
let fallback = 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 {
match lightcraft_gpu::try_render(src, info, s, req, stages) {
Ok(r) => return (r, None),
Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why),
Err(lightcraft_gpu::Fallback::NotTried) => None,
}
} else {
None
};
let rendered = match stages {
Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st),
None => lightcraft_pipeline::render(src, info, s, req),
}
};
(rendered, fallback)
}
/// Where a [`QuickJob`]'s stand-in image came from.
@@ -897,6 +986,7 @@ impl QuickJob {
level: SourceLevel::Thumb,
key,
rendered,
gpu_fallback: None,
loaded: None,
quick,
display: None,
@@ -940,8 +1030,18 @@ pub fn source_info(p: &Photo) -> SourceInfo {
// 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() {
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
let relative = p.relative_wb();
let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
SourceInfo {
raw: true,
as_shot_temp: temp,
as_shot_tint: tint,
lens: p.embedded_lens,
relative_wb: relative,
// (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`)
legacy_relative_wb: p.fitted_look() && !relative,
..Default::default()
}
} else {
SourceInfo::default()
}
@@ -1212,6 +1312,23 @@ impl crate::Session {
}
}
/// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor
/// decode behind it.
///
/// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws
/// likewise start from the embedded preview"), and then paid for a render of the raw to replace
/// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px
/// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is
/// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind
/// it delayed the render the user was waiting for.
///
/// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what
/// says the photo still looks the way it did at import. A photo the user edits re-renders
/// through the ordinary path, because its thumbnail key (settings hash) changes.
pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool {
self.embedded_of(p).is_some()
}
/// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the
/// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the
/// raw. `None` for anything else, and where the host installed no loader.
@@ -1254,15 +1371,38 @@ impl crate::Session {
})
}
/// Accept a finished job's loaded source into the cache.
/// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog
/// has wrong is corrected too ([`Self::learn_as_shot_wb`]).
pub fn accept(&mut self, r: &RenderResult) {
if let Some(src) = &r.loaded
&& r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key))
{
if let Some(info) = &src.info {
self.learn_as_shot_wb(r.photo, info);
}
self.media.insert_source(r.photo, r.level, src.clone());
}
}
/// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another:
/// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's
/// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not
/// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot
/// says and what a smart preview renders with. Only for raws edited in Kelvin.
fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) {
let Some(p) = self.catalog.photo(id) else { return };
if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) {
return;
}
let read = (info.as_shot_temp, info.as_shot_tint);
if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) {
return;
}
if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) {
log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0);
}
}
/// Synchronous render (CLI, MCP, tests).
pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result<Rendered, String> {
let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
@@ -1322,6 +1462,9 @@ impl crate::Session {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
let r = self.media.source_ref(&p, level).load_source()?;
let image = r.image.clone();
if let Some(info) = &r.info {
self.learn_as_shot_wb(id, info);
}
self.media.insert_source(id, level, r);
Ok(image)
}
@@ -1356,7 +1499,6 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
#[cfg(test)]
mod thumbnail_hash_tests {
use super::*;
use lightcraft_catalog::Op;
fn session() -> crate::Session {
let mut s = crate::Session::new();
@@ -1466,6 +1608,23 @@ mod tests {
assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix");
}
#[test]
fn hidden_grading_wheels_render_neutral_without_touching_the_photo() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap();
let job = s.loupe_job(id, 64, 64, true).unwrap();
let same = job.clone().with_hidden_grading([false; 3]);
assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged");
assert!(same.view_cache.is_some());
let hidden = job.clone().with_hidden_grading([true, false, false]);
assert_eq!(hidden.settings.grading.shadows.sat, 0.0);
assert_eq!(hidden.settings.grading.highlights.sat, 40.0);
assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key");
assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview");
assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched");
}
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
@@ -1504,6 +1663,81 @@ mod tests {
assert!(s.media.get_source(id, r.level).is_none());
}
/// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as
/// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices
/// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step),
/// and the panel then reads As Shot in Kelvin while the stored settings keep their look.
#[test]
fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() {
use lightcraft_develop::{WbMode, WbScale};
let mut s = crate::Session::new();
let id = s.catalog.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = "SONY ILCE-7M3".into();
p.as_shot_wb = Some((6500.0, 0.0));
let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0);
(d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
assert_eq!(d.wb.scale, WbScale::Legacy);
p.develop = Arc::new(d.clone());
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.selection.ids = vec![id];
s.selection.active = Some(id);
// the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known
let header = s.source_info(id);
assert!(header.raw && !header.relative_wb && header.legacy_relative_wb);
assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0));
// the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices)
let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() };
let mut pixels = Rgb32f::new(64, 64);
for (i, px) in pixels.data.iter_mut().enumerate() {
let v = (i % 64) as f32 / 64.0;
*px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v];
}
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone());
job.source = SourceRef::File {
path: "/photos/DSC00001.ARW".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))),
fallback: None,
denoise: None,
};
let undo_before = s.undo.len();
let request = job.request;
let r = job.run();
let new = r.rendered.as_ref().unwrap().image.data.clone();
// the old pipeline: the same settings against the relative-scale source facts of an older build
let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() };
let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data;
assert_eq!(new, old, "the look of an existing edit changed");
assert_ne!(
new,
lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data
);
// accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched
s.accept(&r);
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.as_shot_wb, Some((4986.0, -2.0)));
assert_eq!(*p.develop, d);
assert_eq!(s.undo.len(), undo_before);
assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id)));
assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom);
// the panel reads the white balance in effect in Kelvin: the value the relative edit renders as
let info = s.source_info(id);
assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0));
let shown = lightcraft_pipeline::local::effective_wb(&info, &d);
assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0)));
assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}");
// accepting again changes nothing more
let log = s.pending_log.len();
s.accept(&r);
assert_eq!(s.pending_log.len(), log);
// a catalog-only (proxy) render of the same settings before the decode gave the same pixels too
let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data;
assert_eq!(proxy, old, "a smart preview of an existing edit changed");
}
#[test]
fn budget_evicts_least_recently_used_across_caches() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
@@ -1529,6 +1763,36 @@ mod tests {
assert_eq!(m.rendered.mem_usage().0, 0);
}
// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view,
// the least recently used goes, and the budget still evicts them like any source
#[test]
fn full_size_originals_are_kept_up_to_the_capacity() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(100 * mb);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default");
m.set_full_capacity(2);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
assert_eq!(m.usage().2.count, 2);
// re-inserting a held photo replaces it instead of taking a second place
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert_eq!(m.usage().2.count, 2);
// 1 is the least recently used now
m.set_full_capacity(1);
assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
m.set_full_capacity(0);
assert_eq!(m.usage().2.count, 1, "never less than one");
m.set_full_capacity(2);
m.insert(PhotoId(3), SourceLevel::Full, img(1000));
m.set_budget(15 * mb);
assert_eq!(m.usage().2.count, 1, "the budget evicts the older original");
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
+62
View File
@@ -0,0 +1,62 @@
//! Soft proofing against an output-device ICC profile (a printer and paper): loads the profile,
//! builds its proof table through the CMS and registers it with the pipeline, which a
//! [`Proof`](lightcraft_pipeline::Proof) then names by id.
use lightcraft_codecs::display::MAX_PROFILE_BYTES;
pub use lightcraft_codecs::proof::ProofIntent;
/// A device profile ready to proof against.
#[derive(Clone, Debug, PartialEq)]
pub struct ProofProfile {
/// Pass as [`Proof::profile`](lightcraft_pipeline::Proof::profile).
pub id: u64,
/// The profile's description, else the file name.
pub name: String,
}
/// Load the profile at `path` for proofing with `intent`, simulating paper and ink or not.
pub fn load(path: &str, intent: ProofIntent, paper_ink: bool) -> Result<ProofProfile, String> {
let path = path.trim();
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if meta.len() > MAX_PROFILE_BYTES as u64 {
return Err(format!("{path}: too large for an ICC profile"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let fallback = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
from_bytes(&bytes, intent, paper_ink, &fallback).map_err(|e| format!("{path}: {e}"))
}
/// [`load`] from the profile's bytes; `fallback_name` names a profile without a description.
pub fn from_bytes(bytes: &[u8], intent: ProofIntent, paper_ink: bool, fallback_name: &str) -> Result<ProofProfile, String> {
let table = lightcraft_codecs::proof::proof_table(bytes, intent, paper_ink)?;
let lut = lightcraft_pipeline::ProofLut::new(table.n, table.data).ok_or("the profile's tables give no usable colours")?;
let id = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(bytes, intent, paper_ink).hash(&mut h);
h.finish()
};
let id = lightcraft_pipeline::register_proof_profile(id, lut);
Ok(ProofProfile { id, name: table.description.unwrap_or_else(|| fallback_name.to_string()) })
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_codecs::space::NamedSpace;
#[test]
fn profiles_register_by_content_and_options() {
let p3 = lightcraft_codecs::icc::write_named(NamedSpace::DisplayP3);
let a = from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap();
let b = from_bytes(&p3, ProofIntent::Perceptual, true, "x").unwrap();
assert_ne!(a.id, b.id);
assert_eq!(a, from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap());
assert!(lightcraft_pipeline::proof_profile(a.id).is_some());
assert!(!a.name.is_empty());
assert!(from_bytes(b"nope", ProofIntent::Relative, false, "x").is_err());
assert!(load("/nonexistent/printer.icc", ProofIntent::Relative, false).is_err());
assert_eq!(ProofIntent::parse("Relative"), Some(ProofIntent::Relative));
assert_eq!(ProofIntent::parse("saturation"), None);
}
}
+580 -60
View File
@@ -319,6 +319,27 @@ pub(crate) trait MoveFs {
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
/// Content observed in a packet, independent of its pathname or inode. Model volumes
/// override this; disk implementations stream a bounded buffer without retaining the XML.
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hash = lightcraft_preview::Hasher128::new();
let mut len = 0u64;
let mut buffer = [0u8; 16 * 1024];
loop {
let n = match file.read(&mut buffer) {
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
result => result?,
};
if n == 0 {
return Ok(PacketVersion { len, hash: hash.finish() });
}
let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?;
len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?;
hash.update(bytes);
}
}
}
/// The real file system.
@@ -370,7 +391,8 @@ impl MoveFs for RealFs {
let _ = std::fs::remove_file(b);
}),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
Err(_) => std::fs::rename(a, b),
Err(_) => std::fs::rename(a, b)
.or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }),
}
}
@@ -392,6 +414,56 @@ impl MoveFs for RealFs {
}
}
/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original
/// byte for byte, and only then remove the original. A copy that doesn't match is removed.
fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
fs.copy_no_replace(a, b)?;
if !same_contents(a, b).unwrap_or(false) {
let _ = std::fs::remove_file(b);
return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display())));
}
std::fs::remove_file(a).inspect_err(|_| {
// the original stays: drop the copy so the file isn't in two places
let _ = std::fs::remove_file(b);
})
}
/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so
/// a large raw or video is never read into memory whole.
fn same_contents(a: &Path, b: &Path) -> std::io::Result<bool> {
const CHUNK: usize = 1 << 20;
let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?);
if fa.metadata()?.len() != fb.metadata()?.len() {
return Ok(false);
}
let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]);
loop {
let n = read_full(&mut fa, &mut ba)?;
let m = read_full(&mut fb, &mut bb)?;
if n != m || ba.get(..n) != bb.get(..m) {
return Ok(false);
}
if n == 0 {
return Ok(true);
}
}
}
/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer
/// only at the end of the file).
fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) {
match r.read(rest) {
Ok(0) => break,
Ok(k) => filled = filled.saturating_add(k),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(filled)
}
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
/// volume): the rename must go through a temporary name.
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
@@ -420,6 +492,178 @@ fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> {
})
}
/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may
/// already have followed its new path, so a retry cannot infer this work from the catalog.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SidecarMove {
pub from: PathBuf,
pub to: PathBuf,
copied: bool,
/// Observed packets in the partial move, including the separated source. Absent planned
/// destinations are never owned; a completed repair advances these observations.
companions: Vec<RetainedPacket>,
}
/// A content version, not an assertion that a pathname still names the same packet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PacketVersion {
len: u64,
hash: lightcraft_preview::Hash128,
}
impl PacketVersion {
fn of(bytes: &[u8]) -> Self {
Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) }
}
}
#[derive(Clone, Debug, PartialEq)]
struct RetainedPacket {
path: PathBuf,
// An unreadable observation remains untrusted even if a later read succeeds.
version: Option<PacketVersion>,
}
impl RetainedPacket {
fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> {
if self.version.is_none() {
return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display()));
}
let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?;
if self.version != Some(current) {
return Err(format!(
"retained XMP {} changed since the partial move; packet ownership must be resolved before retrying",
self.path.display()
));
}
Ok(())
}
}
/// Advance only packets whose operation actually succeeded. A copy retains its original;
/// a move retires vanished source aliases and adds the observed destination.
fn advance_packets(packets: &mut Vec<RetainedPacket>, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) {
let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return };
if !copied {
packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path));
}
packet.path = to.to_path_buf();
if !packets.iter().any(|p| p.path == packet.path) {
packets.push(packet);
}
}
fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec<PathBuf> {
packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect()
}
impl SidecarMove {
fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> {
for packet in &self.companions {
packet.validate(fs)?;
}
Ok(())
}
/// Validate a queued retry and return the copy decision for this physical operation.
fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result<bool, String> {
self.validate_packets(fs)?;
let respelling = is_respelling(fs, &self.from, &self.to);
// Batch rollback may have restored another photo using this source packet.
let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some());
if fs.exists(&self.to) && (copied || !respelling) {
return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display()));
}
Ok(copied)
}
fn needed(&self, fs: &dyn MoveFs) -> bool {
fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to)))
}
fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool {
let same_path = |endpoint: &Path| {
endpoint == path
|| fs.same_file(endpoint, path)
|| (endpoint.file_name() == path.file_name()
&& endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b)))
};
same_path(&self.from) || same_path(&self.to)
}
fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> {
if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) }
}
}
/// Complete known unfinished sidecars before moving any more photos. Completed operations
/// leave the queue; a failure retains that operation and the rest, without replacing a file.
pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>) -> std::result::Result<(), String> {
validate_sidecars(fs, pending)?;
while !pending.is_empty() {
apply_repair(fs, pending, 0)?;
}
Ok(())
}
/// Preflight a detached history entry before global queues are allowed to retire. The
/// caller can validate without mutating it; retry revalidates before each physical operation.
pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> {
for repair in pending {
repair.validate_repair(fs)?;
}
Ok(())
}
/// Finish one operation and transfer its observed content to the remaining repairs. Do not
/// infer ownership by reading an occupied destination or by retaining a planned pathname.
fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>, i: usize) -> std::result::Result<(), String> {
let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) };
repair.copied = repair.validate_repair(fs)?;
let aliases = packet_aliases(&repair.companions, fs, &repair.from);
repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?;
for remaining in pending.iter_mut() {
advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs);
}
pending.remove(i);
Ok(())
}
/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals
/// must not block healthy metadata saves. Completed repairs retire; failures stay queued.
pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>, paths: &[PathBuf]) -> std::result::Result<(), String> {
for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) {
repair.validate_repair(fs)?;
}
let mut i = 0;
while let Some(m) = pending.get(i) {
if paths.iter().any(|path| m.touches(fs, path)) {
// Path/alias equality only locates an observation; validation above proves its
// content. A newly occupied, previously absent peer destination is a collision.
if let Some(path) = paths.iter().find(|path| {
fs.exists(path)
&& !fs.same_file(path, &m.from)
&& !fs.same_file(path, &m.to)
&& !m.companions.iter().any(|known| fs.same_file(path, &known.path))
}) {
return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display()));
}
apply_repair(fs, pending, i)?;
} else {
i += 1;
}
}
Ok(())
}
/// Every record came from an actual packet observed before this move. Successful physical
/// operations have already advanced its path; missing planned destinations are not recorded.
fn note_companions(pending: &mut [SidecarMove], packets: Vec<RetainedPacket>) {
for repair in pending {
repair.companions.clone_from(&packets);
}
}
/// Why [`move_file`] failed, and what it left behind.
#[derive(Clone, Debug, PartialEq)]
pub struct MoveError {
@@ -428,6 +672,7 @@ pub struct MoveError {
/// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file
/// back failed too. Its sidecars that did move stay with it.
pub moved: bool,
pub(crate) pending_sidecars: Vec<SidecarMove>,
}
impl std::fmt::Display for MoveError {
@@ -447,53 +692,82 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu
if Path::new(from) == Path::new(to) {
return Ok(());
}
let fail = |message: String| MoveError { message, moved: false };
let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() };
let (f, t) = (Path::new(from), Path::new(to));
// `to` may be this very file spelled differently (case-insensitive volume); any other file
// there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced
if fs.exists(t) && !is_respelling(fs, f, t) {
return Err(fail(format!("{to} already exists")));
}
let mut packets: Vec<RetainedPacket> = sidecars(from)
.into_iter()
.map(|(p, _, _)| p)
.filter(|p| fs.is_file(p))
.map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path })
.collect();
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
// sidecars moved (or copied: `true`)
let mut done: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
for (sc, naming, upper) in sidecars(from) {
if !fs.is_file(&sc) {
continue;
}
let mut done: Vec<SidecarMove> = Vec::new();
let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| {
let mut dst = crate::sidecar::sidecar_path(to, naming);
if upper {
dst = dst.with_extension("XMP");
}
// a stem sidecar another file still uses (`IMG_1.xmp` of `IMG_1.CR3` and `IMG_1.JPG`)
// is copied, not taken away from it (issue #92)
let shared = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
if fs.exists(&dst) && (shared || !is_respelling(fs, &sc, &dst)) {
// A stem sidecar another photo still uses is copied, not taken away (issue #92).
let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
SidecarMove { from: sc, to: dst, copied, companions: Vec::new() }
});
while let Some(m) = remaining.next() {
if !m.needed(fs) {
continue; // never overwrite: the old sidecar stays where it was
}
let r = if shared { fs.copy_no_replace(&sc, &dst) } else { move_one(fs, &sc, &dst) };
if let Err(e) = r {
let mut message = format!("rename {}: {e}", sc.display());
// the file first: if it can't go back, the sidecars that moved stay with it
let aliases = packet_aliases(&packets, fs, &m.from);
if let Err(e) = m.apply(fs) {
let mut message = format!("rename {}: {e}", m.from.display());
// The file first: if it can't go back, sidecars that moved stay with it.
if let Err(e) = move_one(fs, t, f) {
message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}"));
if !done.is_empty() {
let names: Vec<String> = done.iter().map(|(_, b, _)| b.display().to_string()).collect();
let names: Vec<String> = done.iter().map(|m| m.to.display().to_string()).collect();
message.push_str(&format!(" with its sidecar {}", names.join(", ")));
}
return Err(MoveError { message, moved: true });
let mut pending_sidecars = vec![m];
for m in remaining.filter(|m| m.needed(fs)) {
// The two case spellings can name one file. Keep one operation for
// those aliases, while retaining distinct sidecars on sensitive volumes.
let alias = pending_sidecars.iter().any(|p| {
folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy())
&& folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy())
&& fs.same_file(&p.from, &m.from)
});
if !alias {
pending_sidecars.push(m);
}
}
note_companions(&mut pending_sidecars, packets);
return Err(MoveError { message, moved: true, pending_sidecars });
}
for (a, b, copied) in done.iter().rev() {
if *copied {
// the original is still there: the copy just goes (a leftover copy loses nothing)
let _ = fs.remove_file(b);
} else if let Err(e) = move_one(fs, b, a) {
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", a.display(), b.display()));
let mut pending_sidecars = Vec::new();
for m in done.iter().rev() {
if m.copied {
// The original is still there: a leftover copy loses nothing.
if fs.remove_file(&m.to).is_ok() {
packets.retain(|p| p.path != m.to);
}
} else {
let aliases = packet_aliases(&packets, fs, &m.to);
if let Err(e) = move_one(fs, &m.to, &m.from) {
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display()));
pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() });
} else {
advance_packets(&mut packets, &aliases, &m.from, false, fs);
}
}
}
return Err(fail(message));
note_companions(&mut pending_sidecars, packets);
return Err(MoveError { message, moved: false, pending_sidecars });
}
done.push((sc, dst, shared));
advance_packets(&mut packets, &aliases, &m.to, m.copied, fs);
done.push(m);
}
Ok(())
}
@@ -505,6 +779,8 @@ pub(crate) struct BatchMoveError {
pub message: String,
/// Moves that stayed done, (from, to): those files are at `to` now.
pub stuck: Vec<(String, String)>,
/// Sidecars still separated from the path where their photo ended up, in either direction.
pub pending_sidecars: Vec<SidecarMove>,
}
/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone.
@@ -517,30 +793,41 @@ pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::resu
continue;
};
let mut message = e.message;
let mut pending_sidecars = e.pending_sidecars;
let mut stuck = Vec::new();
if e.moved {
stuck.push(m.clone());
}
for d in done.iter().rev() {
if let Err(e) = move_file_with(fs, &d.1, &d.0) {
if let Err(mut e) = move_file_with(fs, &d.1, &d.0) {
message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message));
if !e.moved {
stuck.push((*d).clone());
}
pending_sidecars.append(&mut e.pending_sidecars);
}
}
return Err(BatchMoveError { message, stuck });
return Err(BatchMoveError { message, stuck, pending_sidecars });
}
Ok(())
}
/// The `SetFile` ops in `op` (nested batches included) that point a photo at one of `to`.
/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward.
fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
match op {
Op::Batch { ops } => ops.iter().for_each(|o| set_file_ops_to(o, to, out)),
Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str()) => out.push(op.clone()),
_ => {}
let mut pending = vec![op];
let mut order = Vec::new();
let mut final_ops = HashMap::new();
while let Some(op) = pending.pop() {
match op {
Op::Batch { ops } => pending.extend(ops.iter().rev()),
Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id),
_ => {}
}
}
out.extend(order.into_iter().filter_map(|id| {
let op = final_ops.get(&id).copied()?;
matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone())
}));
}
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
@@ -650,8 +937,69 @@ impl Session {
self.apply_rename_with(&RealFs, plans)
}
/// Complete retained repairs before a file or folder change can stale their exact paths.
pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> {
for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) {
repair.validate_repair(fs).map_err(EngineError::Other)?;
}
retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?;
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?;
}
Ok(())
}
pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> {
self.validate_sidecar_packets_for_paths(paths, fs)?;
retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?;
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?;
}
Ok(())
}
/// A healthy shared packet may be saved while another photo's repair is unavailable.
/// Check its retained content too, without forcing that unrelated physical operation.
pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> {
for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) {
if paths.iter().any(|path| repair.touches(fs, path)) {
repair.validate_repair(fs).map_err(EngineError::Other)?;
} else {
for packet in &repair.companions {
if paths.iter().any(|path| fs.same_file(path, &packet.path)) {
packet.validate(fs).map_err(EngineError::Other)?;
}
}
}
}
Ok(())
}
/// Only a successful application write updates a retained content version. Atomic writes
/// replace inodes; aliases that now resolve to the written path observe the new content.
pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) {
let version = PacketVersion::of(bytes);
for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) {
for packet in &mut repair.companions {
if RealFs.same_file(path, &packet.path) {
packet.version = Some(version);
}
}
}
}
/// [`Session::apply_rename`] on the file system `fs`.
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }))
}
/// Carry out `plans` (renames or moves to another folder): move the files with their sidecars
/// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the
/// photos (and their virtual copies) at the new paths. Undo moves the files back.
pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result<usize> {
// A partly completed rename can already have the requested photo name. Repair
// its sidecars before treating that empty move list as a successful retry.
self.repair_sidecars_with(fs)?;
let mut ops = Vec::new();
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
for p in self.catalog.photos() {
@@ -679,10 +1027,10 @@ impl Session {
})
.collect();
if let Err(e) = move_all(fs, &moves) {
return Err(EngineError::Other(self.follow_stuck(&op, e, true)));
return Err(EngineError::Other(self.follow_stuck(&op, e, true).0));
}
if n > 0
&& let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op)
&& let Err(err) = self.commit(&label(n), op)
{
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
if let Err(e) = move_all(fs, &back) {
@@ -695,11 +1043,13 @@ impl Session {
/// After a failed batch of moves for `op`: point the photos whose files stayed moved at
/// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged
/// change), so the library matches the disk. Returns the message for the user.
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> String {
/// change), so the library matches the disk. Returns the message and, for a history retry,
/// the correction's inverse file ops (so a retry doesn't lose the original paths).
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec<Op>) {
let mut msg = e.message;
if e.stuck.is_empty() {
return msg;
let pending_sidecars = e.pending_sidecars;
if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) {
return (msg, Vec::new());
}
let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect();
let mut keep = Vec::new();
@@ -708,56 +1058,97 @@ impl Session {
let n = e.stuck.len();
let s = if n == 1 { "" } else { "s" };
let fix = Op::Batch { ops: keep };
let mut inverses = Vec::new();
let applied = if undoable {
self.commit(&format!("Rename {n} Photo{s} (partly)"), fix)
let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") };
self.commit(&label, fix).map(|()| {
if let Some(entry) = self.undo.last_mut() {
entry.retry_sidecars.extend(pending_sidecars);
}
})
} else {
match self.catalog.apply(fix.clone()) {
Ok(_) => {
match self.apply_catalog(fix.clone()) {
Ok(inv) => {
inverses = match inv {
Op::Batch { ops } => ops,
op => vec![op],
};
self.pending_log.push(fix);
Ok(())
}
Err(e) => Err(e.into()),
Err(e) => Err(e),
}
};
match applied {
Ok(()) => msg
Ok(()) if n > 0 => msg
.push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))),
Ok(()) => {}
Err(err) => msg.push_str(&format!(
". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}",
list.join(", ")
)),
}
msg
(msg, inverses)
}
/// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's
/// current one.
/// Physical moves to the final `SetFile` destination of each photo. A merged history
/// batch can rename the same photo repeatedly; intermediate catalog paths need no file.
pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> {
fn rec(s: &Session, op: &Op, v: &mut Vec<(String, String)>) {
let mut pending = vec![op];
let mut order = Vec::new();
let mut targets = HashMap::new();
while let Some(op) = pending.pop() {
match op {
Op::Batch { ops } => ops.iter().for_each(|o| rec(s, o, v)),
Op::SetFile { id, source: Source::File { path: to }, .. } => {
if let Some(Source::File { path: from }) = s.catalog.photo(*id).map(|p| &p.source)
&& Path::new(from) != Path::new(to)
&& !v.iter().any(|(f, _)| f == from)
{
v.push((from.clone(), to.clone()));
}
Op::Batch { ops } => pending.extend(ops.iter().rev()),
Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => {
order.push(*id);
}
_ => {}
}
}
let mut v = Vec::new();
rec(self, op, &mut v);
v
// Virtual copies share one physical move, keeping the original batch order.
let mut seen = HashSet::new();
order
.into_iter()
.filter_map(|id| {
let Source::File { path: to } = targets.get(&id).copied()? else { return None };
let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None };
(Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone()))
})
.collect()
}
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use std::cell::RefCell;
/// The cross-volume move's check compares files chunk by chunk: equal files across several
/// chunks, a difference in the last chunk, different lengths, a missing file.
#[test]
fn same_contents_compares_in_chunks() {
let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let big: Vec<u8> = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect();
let (a, b) = (dir.join("a"), dir.join("b"));
std::fs::write(&a, &big).unwrap();
std::fs::write(&b, &big).unwrap();
assert!(same_contents(&a, &b).unwrap());
let mut other = big.clone();
*other.last_mut().unwrap() ^= 1;
std::fs::write(&b, &other).unwrap();
assert!(!same_contents(&a, &b).unwrap());
std::fs::write(&b, &big[..big.len() - 1]).unwrap();
assert!(!same_contents(&a, &b).unwrap());
std::fs::write(&a, b"").unwrap();
std::fs::write(&b, b"").unwrap();
assert!(same_contents(&a, &b).unwrap());
assert!(same_contents(&a, &dir.join("missing")).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or
/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail.
pub(crate) struct FakeFs {
@@ -791,6 +1182,10 @@ mod tests {
}
impl MoveFs for FakeFs {
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
let i = self.idx(path).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such packet"))?;
self.files.borrow().get(i).map(|(_, bytes)| PacketVersion::of(bytes)).ok_or_else(|| std::io::Error::other("packet disappeared"))
}
fn exists(&self, p: &Path) -> bool {
self.idx(p).is_some()
}
@@ -812,6 +1207,9 @@ mod tests {
Ok(())
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
return Err(std::io::Error::other("injected failure"));
}
if self.exists(b) {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
}
@@ -841,6 +1239,101 @@ mod tests {
Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
}
/// Compare the unfinished operation and every observed packet after partial progress.
/// Companion order is incidental; paths and recorded content versions are exact.
pub(crate) fn assert_repair_packets(actual: &SidecarMove, unfinished: &SidecarMove, packets: &[(PathBuf, &[u8])]) {
let mut expected = unfinished.clone();
expected.companions =
packets.iter().map(|(path, bytes)| RetainedPacket { path: path.clone(), version: Some(PacketVersion::of(bytes)) }).collect();
let mut actual = actual.clone();
actual.companions.sort_by(|a, b| a.path.cmp(&b.path));
expected.companions.sort_by(|a, b| a.path.cmp(&b.path));
assert_eq!(actual, expected);
}
#[test]
fn folder_moves_repair_retained_sidecars_before_changing_paths() {
for command in ["folder.rename", "folder.move"] {
for queue in ["pending", "undo", "redo"] {
for (naming, copied) in [(SidecarNaming::Stem, false), (SidecarNaming::Full, false), (SidecarNaming::Stem, true)] {
let root = crate::tests_xmp::temp_dir("folder-sidecar-repair");
let from = root.join("Trip");
std::fs::create_dir_all(&from).unwrap();
let photo = from.join("b.jpg");
std::fs::write(&photo, b"Photo bytes").unwrap();
let old_sidecar = crate::sidecar::sidecar_path(&from.join("a.jpg").to_string_lossy(), naming);
let sidecar = crate::sidecar::sidecar_path(&photo.to_string_lossy(), naming);
std::fs::write(&old_sidecar, b"Retained edits").unwrap();
// An ordinary occupied destination must block repair without replacing its bytes.
std::fs::write(&sidecar, b"Other edits").unwrap();
let mut s = Session::new();
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, &photo.to_string_lossy())) }).unwrap();
if copied {
let sibling = from.join("a.raw");
std::fs::write(&sibling, b"Shared original").unwrap();
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(2, &sibling.to_string_lossy())) }).unwrap();
}
s.commit("Rating", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
if queue == "redo" {
s.undo_step().unwrap();
}
let repair = SidecarMove {
from: old_sidecar.clone(),
to: sidecar.clone(),
copied,
companions: vec![RetainedPacket { path: old_sidecar.clone(), version: Some(RealFs.packet_version(&old_sidecar).unwrap()) }],
};
match queue {
"undo" => s.undo.last_mut().unwrap().retry_sidecars.push(repair),
"redo" => s.redo.last_mut().unwrap().retry_sidecars.push(repair),
_ => s.pending_sidecar_repairs.push(repair),
}
let params = if command == "folder.rename" {
serde_json::json!({"path": from, "name": "Holiday"})
} else {
serde_json::json!({"path": from, "into": root.join("Archive")})
};
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") };
let before = s.catalog.to_snapshot();
let history = (s.undo.len(), s.redo.len());
assert!(s.execute(command, &params).is_err());
assert!(from.is_dir() && !to.exists());
assert_eq!(s.catalog.to_snapshot(), before);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(std::fs::read(&old_sidecar).unwrap(), b"Retained edits");
assert_eq!(std::fs::read(&sidecar).unwrap(), b"Other edits");
std::fs::remove_file(&sidecar).unwrap();
s.execute(command, &params).unwrap();
assert!(s.pending_sidecar_repairs.is_empty());
assert!(s.undo.iter().chain(&s.redo).all(|e| e.retry_sidecars.is_empty()));
let moved_sidecar = to.join(sidecar.file_name().unwrap());
assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits");
assert_eq!(to.join(old_sidecar.file_name().unwrap()).exists(), copied);
for (step, current) in [("edit.undo", &from), ("edit.redo", &to)] {
s.execute(step, &serde_json::json!({})).unwrap();
assert_eq!(
s.catalog.photo(PhotoId(1)).unwrap().source,
Source::File { path: current.join("b.jpg").to_string_lossy().into_owned() }
);
assert_eq!(std::fs::read(current.join(sidecar.file_name().unwrap())).unwrap(), b"Retained edits");
assert_eq!(current.join(old_sidecar.file_name().unwrap()).exists(), copied);
}
// Explicit IDs choose targets after the command's selection gate.
s.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap();
s.execute("photo.rename", &serde_json::json!({"ids": [1], "template": "Latest"})).unwrap();
s.execute("edit.undo", &serde_json::json!({})).unwrap();
assert!(to.join("b.jpg").is_file());
assert_eq!(std::fs::read(&moved_sidecar).unwrap(), b"Retained edits");
s.execute("edit.redo", &serde_json::json!({})).unwrap();
assert!(to.join("Latest.jpg").is_file());
let final_sidecar = crate::sidecar::sidecar_path(&to.join("Latest.jpg").to_string_lossy(), naming);
assert_eq!(std::fs::read(final_sidecar).unwrap(), b"Retained edits");
std::fs::remove_dir_all(root).unwrap();
}
}
}
}
#[cfg(windows)]
#[test]
fn mixed_separator_noop_rename_preserves_files_catalog_and_undo() {
@@ -981,6 +1474,33 @@ mod tests {
assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d.")));
}
#[test]
fn pending_sidecars_preserve_shared_copies_and_do_not_repeat_case_aliases() {
// These are synthetic volume semantics; no host driver or OS runtime is involved.
for ci in [false, true] {
let fs = FakeFs::new(ci, &[("/p/a.CR3", "Raw"), ("/p/a.JPG", "Jpeg"), ("/p/a.XMP", "Shared edits"), ("/p/a.CR3.XMP", "Raw edits")]);
fs.fail.borrow_mut().extend(["/p/a.XMP".into(), "/p/b.CR3".into()]);
let mut error = move_file_with(&fs, "/p/a.CR3", "/p/b.CR3").unwrap_err();
assert!(error.moved);
assert_eq!(error.pending_sidecars.len(), 2);
assert!(error.pending_sidecars[0].copied);
assert!(!error.pending_sidecars[1].copied);
fs.fail.borrow_mut().clear();
retry_sidecars(&fs, &mut error.pending_sidecars).unwrap();
assert!(error.pending_sidecars.is_empty());
assert_eq!(
fs.listing(),
vec![
("/p/a.JPG".into(), "Jpeg".into()),
("/p/a.XMP".into(), "Shared edits".into()),
("/p/b.CR3".into(), "Raw".into()),
("/p/b.CR3.XMP".into(), "Raw edits".into()),
("/p/b.XMP".into(), "Shared edits".into()),
]
);
}
}
/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared
/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it.
#[test]
+133 -7
View File
@@ -104,6 +104,9 @@ pub struct SidecarData {
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
pub captured: Option<String>,
pub develop: Option<DevelopPatch>,
/// Actionable diagnostics for foreign develop settings that were not fully carried over.
/// These are surfaced in the photo import report and never persisted in the catalog.
pub warnings: Vec<crate::import::ImportWarning>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -165,7 +168,65 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu
out.develop = match full {
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
None if crate::crs::has_adjustments(&d.properties) => {
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT);
// Lightroom's default profiles are named by nearly every sidecar; our own starting look stands
// in for them, so only another profile is worth a warning
let profile = crate::crs::camera_profile_name(&d.properties)
.filter(|name| !matches!(name.trim(), "Adobe Standard" | "Adobe Color"))
.map(str::to_string);
if let Some(name) = profile {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::CameraProfile,
profile: Some(name),
mode: None,
fields: Vec::new(),
});
}
let upright = crate::crs::has_saved_upright_transform(&d.properties);
if upright {
let mode = d
.properties
.get("crs:PerspectiveUpright")
.and_then(|values| values.first())
.map(String::as_str)
.map(|mode| match mode {
"0" => crate::import::ImportUprightMode::Off,
"1" => crate::import::ImportUprightMode::Auto,
"2" => crate::import::ImportUprightMode::Level,
"3" => crate::import::ImportUprightMode::Vertical,
"4" => crate::import::ImportUprightMode::Full,
"5" => crate::import::ImportUprightMode::Guided,
_ => crate::import::ImportUprightMode::Unknown,
})
.unwrap_or(crate::import::ImportUprightMode::Unknown);
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UprightGeometry,
profile: None,
mode: Some(mode),
fields: Vec::new(),
});
}
let other: Vec<&str> = unmapped
.iter()
.map(String::as_str)
.filter(|name| {
*name != "CameraProfile"
&& !name.starts_with("UprightTransform_")
&& !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion")
})
.collect();
if !other.is_empty() {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UnmappedXmp,
profile: None,
mode: None,
fields: other.into_iter().map(str::to_string).collect(),
});
}
Some(DevelopPatch::Partial(partial))
}
None => None,
};
@@ -248,6 +309,12 @@ impl SidecarData {
/// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom
/// label names included, as other editors do).
pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
sidecar_packet_with(p, cat, false)
}
/// [`sidecar_packet`], without any location (GPS, sublocation, city, state, country) when
/// `remove_location` is set (an export with "Remove location info").
pub fn sidecar_packet_with(p: &Photo, cat: &lightcraft_catalog::Catalog, remove_location: bool) -> String {
let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
let meta = lightcraft_meta::Metadata {
software: Some("LightCraft".into()),
@@ -271,6 +338,10 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
..Default::default()
};
let mut meta = meta;
if remove_location {
(meta.sublocation, meta.city, meta.state, meta.country, meta.gps) = (None, None, None, None, None);
}
let settings = serde_json::to_string(&*p.develop).unwrap_or_default();
let flag = match p.flag {
Flag::None => "none",
@@ -278,7 +349,7 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
Flag::Reject => "reject",
};
let mut lc = vec![("settings", settings.as_str()), ("flag", flag)];
if !p.meta.location.trim().is_empty() {
if !remove_location && !p.meta.location.trim().is_empty() {
lc.push(("location", p.meta.location.as_str()));
}
lightcraft_meta::write_xmp_lc(&meta, &lc)
@@ -468,30 +539,60 @@ impl Session {
}
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
pub fn save_sidecar(&self, id: PhotoId) -> Result<SidecarSaved> {
pub fn save_sidecar(&mut self, id: PhotoId) -> Result<SidecarSaved> {
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
}
pub(crate) fn save_sidecar_with(&self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
if p.copy_of.is_some() {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
let path = sidecar_path(orig, owners.naming(p, self.xmp.naming));
let naming = owners.naming(p, self.xmp.naming);
let preferred = sidecar_path(orig, naming);
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
let candidates: Vec<PathBuf> = [naming, other]
.into_iter()
.filter(|n| *n != SidecarNaming::Stem || owners.naming(p, SidecarNaming::Stem) == SidecarNaming::Stem)
.flat_map(|n| {
let path = sidecar_path(orig, n);
[path.clone(), path.with_extension("XMP")]
})
.collect();
// A separated packet is still the photo's metadata. Repair it before a fresh write
// occupies its destination; a failure preserves the queue and both existing packets.
self.repair_sidecars_for_paths(&candidates, &crate::rename::RealFs)
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
// Keep an existing packet's spelling/style so foreign properties survive this save and
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred);
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?;
write_atomic(&path, data.as_bytes()).map_err(err)?;
self.note_sidecar_write(&path, data.as_bytes());
Ok(SidecarSaved { path, merged, backup })
}
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
self.read_sidecar_op_with_warnings(id).map(|result| result.map(|(op, from, _)| (op, from)))
}
/// Like [`Session::read_sidecar_op`], retaining transient diagnostics for the command report.
pub fn read_sidecar_op_with_warnings(&self, id: PhotoId) -> Result<Option<(Op, PathBuf, Vec<crate::import::ImportWarning>)>> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let Some(orig) = file_path(p) else { return Ok(None) };
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
Ok(Some((self.sidecar_op(id, sc)?, from)))
let mut warnings = sc.warnings.clone();
for warning in &mut warnings {
warning.path = orig.to_string();
}
let op = self.sidecar_op(id, sc)?;
Ok(Some((op, from, warnings)))
}
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
@@ -517,9 +618,22 @@ impl Session {
}
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
pub(crate) fn auto_write_sidecars(&mut self, ops: &[Op]) {
let mut ids = Vec::new();
ops.iter().for_each(|o| op_photos(o, &mut ids));
self.auto_write_sidecar_ids(ids);
}
/// Gather changed IDs before the saves mutate retained physical repair queues.
pub(crate) fn auto_write_sidecars_since(&mut self, log_start: usize) {
let mut ids = Vec::new();
if let Some(ops) = self.pending_log.get(log_start..) {
ops.iter().for_each(|o| op_photos(o, &mut ids));
}
self.auto_write_sidecar_ids(ids);
}
fn auto_write_sidecar_ids(&mut self, ids: Vec<PhotoId>) {
if ids.is_empty() {
return;
}
@@ -633,3 +747,15 @@ mod tests {
assert_eq!(v, vec![PhotoId(1), PhotoId(3)]);
}
}
#[cfg(test)]
#[path = "sidecar/tests_repair_saves.rs"]
mod tests_repair_saves;
#[cfg(test)]
#[path = "sidecar/tests_repair_companions.rs"]
mod tests_repair_companions;
#[cfg(test)]
#[path = "sidecar/tests_repair_provenance.rs"]
mod tests_repair_provenance;
@@ -0,0 +1,287 @@
//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history.
use super::*;
use crate::rename::{MoveFs, RealFs};
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
struct Faults(Vec<PathBuf>);
impl MoveFs for Faults {
fn exists(&self, p: &Path) -> bool {
RealFs.exists(p)
}
fn is_file(&self, p: &Path) -> bool {
RealFs.is_file(p)
}
fn same_file(&self, a: &Path, b: &Path) -> bool {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.0.iter().any(|p| p == a) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move"))
} else {
RealFs.rename_no_replace(a, b)
}
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
RealFs.copy_no_replace(a, b)
}
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
RealFs.remove_file(p)
}
fn stem_sibling(&self, p: &Path) -> Option<String> {
RealFs.stem_sibling(p)
}
}
fn packet(marker: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:fixture="urn:lightcraft:companion-test" xmp:Rating="2" fixture:Marker="{marker}"/></rdf:RDF></x:xmpmeta>"#
)
}
fn queues(s: &Session) -> Vec<crate::rename::SidecarMove> {
s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|e| &e.retry_sidecars)).cloned().collect()
}
fn session(path: &Path) -> Session {
let mut s = Session::new();
let photo = Photo::new(
PhotoId(1),
Source::File { path: path.to_string_lossy().into_owned() },
path.file_name().unwrap().to_str().unwrap(),
"JPEG",
4,
3,
"",
);
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
s.selection = crate::Selection::single(PhotoId(1));
s
}
fn save(s: &mut Session) -> serde_json::Value {
s.execute("photo.saveMetadataToFile", &json!({})).unwrap()
}
fn assert_fresh(path: &Path) {
let xml = std::fs::read_to_string(path).unwrap();
assert!(xml.contains("fixture:Marker="), "foreign property must survive real serialization/merge");
let data = parse_sidecar(&xml, crate::crs::Target::Rendered).unwrap();
assert_eq!(data.title.as_deref(), Some("Fresh"));
assert!(data.keywords.unwrap().contains(&"café".to_string()));
}
#[test]
fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions() {
for upper_stem in [false, true] {
for upper_full in [false, true] {
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for auto in [false, true] {
let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}"));
let a = dir.join("a.jpg");
let b = dir.join("Trip.jpg");
std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap();
let path = |photo: &Path, style, upper| {
let path = sidecar_path(&photo.to_string_lossy(), style);
if upper { path.with_extension("XMP") } else { path }
};
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
let full_a = path(&a, SidecarNaming::Full, upper_full);
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
let full_b = path(&b, SidecarNaming::Full, upper_full);
let stem_bytes = packet("stem-keep");
let full_bytes = packet("full-keep");
std::fs::write(&stem_a, &stem_bytes).unwrap();
std::fs::write(&full_a, &full_bytes).unwrap();
let mut s = session(&a);
s.xmp.naming = naming;
s.xmp.auto_write = auto;
let fs = Faults(vec![full_a.clone(), b.clone()]);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
let pending = queues(&s);
assert_eq!(pending.len(), 1);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
// Missing packet and an unrelated packet at either case spelling remain protected.
let held = dir.join("held.xmp");
std::fs::rename(&full_a, &held).unwrap();
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
assert_eq!(queues(&s), pending);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
std::fs::rename(&held, &full_a).unwrap();
for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] {
std::fs::write(&collision, packet("unrelated-packet")).unwrap();
let occupied = std::fs::read(&collision).unwrap();
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1);
assert_eq!(queues(&s), pending);
assert_eq!(std::fs::read(&collision).unwrap(), occupied);
assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes);
assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes);
std::fs::remove_file(collision).unwrap();
}
// AutoWrite is the first successful repair when enabled; explicit save otherwise.
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
if !auto {
assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0);
}
assert!(queues(&s).is_empty());
assert!(!full_a.exists());
let (written, untouched, expected) =
if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) };
assert_fresh(written);
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4));
for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] {
s.execute(command, &json!({})).unwrap();
if !auto {
save(&mut s);
}
assert_fresh(written);
assert_eq!(
parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating,
Some(rating)
);
assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected);
}
assert_eq!(s.xmp.naming, naming);
assert_eq!(s.xmp.auto_write, auto);
drop(s);
std::fs::remove_dir_all(dir).unwrap();
}
}
}
}
}
#[test]
fn rollback_repair_and_shared_copy_keep_known_companions() {
for rollback in [false, true] {
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
let dir = crate::tests_xmp::temp_dir(&format!("r3-companion-rollback-{rollback}-{naming:?}"));
let a = dir.join("a.jpg");
let b = dir.join("Trip.jpg");
std::fs::write(&a, b"physical fixture").unwrap();
let stem_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Stem);
let full_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Full);
let stem_b = sidecar_path(&b.to_string_lossy(), SidecarNaming::Stem);
std::fs::write(&stem_a, packet("stem-keep")).unwrap();
std::fs::write(&full_a, packet("full-keep")).unwrap();
// When the photo cannot roll back, the stem is copied for the physical sibling.
if !rollback {
std::fs::write(dir.join("a.CR3"), b"physical stem sibling").unwrap();
}
let mut s = session(&a);
let fs = Faults(vec![full_a.clone(), if rollback { stem_b.clone() } else { b.clone() }]);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(queues(&s).len(), 1);
let actual = if rollback { &a } else { &b };
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: actual.to_string_lossy().into_owned() });
s.xmp.naming = naming;
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
let result = save(&mut s);
assert!(result["failed"].as_array().unwrap().is_empty(), "{result}");
assert!(queues(&s).is_empty());
assert_fresh(&sidecar_path(&actual.to_string_lossy(), naming));
if !rollback {
assert_eq!(std::fs::read_to_string(&stem_a).unwrap(), packet("stem-keep"));
}
drop(s);
std::fs::remove_dir_all(dir).unwrap();
}
}
}
#[test]
fn a_later_save_recognizes_companions_after_an_earlier_repair_retires() {
let dir = crate::tests_xmp::temp_dir("r3-successive-companions");
let a = dir.join("a.jpg");
let b = dir.join("Trip.jpg");
let stem_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Stem);
let full_a = sidecar_path(&a.to_string_lossy(), SidecarNaming::Full);
let upper_a = full_a.with_extension("XMP");
std::fs::write(&a, b"physical fixture").unwrap();
std::fs::write(&stem_a, packet("stem-keep")).unwrap();
std::fs::write(&full_a, packet("lower-keep")).unwrap();
std::fs::write(&upper_a, packet("upper-keep")).unwrap();
if RealFs.same_file(&full_a, &upper_a) {
std::fs::remove_dir_all(dir).unwrap();
return; // This case requires two physically distinct full packets.
}
let mut s = session(&a);
s.xmp.naming = SidecarNaming::Full;
let fs = Faults(vec![full_a.clone(), upper_a.clone(), b.clone()]);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(queues(&s).len(), 2);
assert!(s.repair_sidecars_with(&Faults(vec![full_a.clone()])).is_err());
assert_eq!(queues(&s).len(), 1);
let upper_b = sidecar_path(&b.to_string_lossy(), SidecarNaming::Full).with_extension("XMP");
assert_eq!(std::fs::read_to_string(&upper_b).unwrap(), packet("upper-keep"));
s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
assert!(save(&mut s)["failed"].as_array().unwrap().is_empty());
assert!(queues(&s).is_empty());
assert_fresh(&sidecar_path(&b.to_string_lossy(), SidecarNaming::Full));
assert_eq!(std::fs::read_to_string(&upper_b).unwrap(), packet("upper-keep"));
assert_eq!(std::fs::read_to_string(sidecar_path(&b.to_string_lossy(), SidecarNaming::Stem)).unwrap(), packet("stem-keep"));
drop(s);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() {
let dir = crate::tests_xmp::temp_dir("queued-stem-batch-rollback");
let a = dir.join("a.jpg");
let b = dir.join("a.png");
let first = dir.join("Trip_1.jpg");
let second = dir.join("Trip_2.png");
let shared = dir.join("a.xmp");
let first_packet = dir.join("Trip_1.xmp");
let second_packet = dir.join("Trip_2.xmp");
let jpeg = b"first physical photo; rename does not decode".to_vec();
let png = b"second physical photo; rename does not decode".to_vec();
let bytes = packet("shared-after-batch-rollback").into_bytes();
std::fs::write(&a, &jpeg).unwrap();
std::fs::write(&b, &png).unwrap();
std::fs::write(&shared, &bytes).unwrap();
let mut s = session(&a);
let photo = Photo::new(PhotoId(2), Source::File { path: b.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
s.xmp.auto_write = false;
// Existing Faults refuses renames only: the first shared-packet copy must succeed.
let faults = Faults(vec![shared.clone(), second.clone()]);
let plans = s.plan_rename_with(&faults, &[PhotoId(1), PhotoId(2)], "Trip_{seq}", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&faults, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: a.to_string_lossy().into_owned() });
assert_eq!(s.catalog.photo(PhotoId(2)).unwrap().source, Source::File { path: second.to_string_lossy().into_owned() });
assert_eq!(std::fs::read(&a).unwrap(), jpeg);
assert_eq!(std::fs::read(&second).unwrap(), png);
assert!(!b.exists() && !first.exists());
assert_eq!(std::fs::read(&shared).unwrap(), bytes);
assert_eq!(std::fs::read(&first_packet).unwrap(), bytes);
assert!(!second_packet.exists());
let pending = queues(&s);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].from, shared);
assert_eq!(pending[0].to, second_packet);
let catalog_before = s.catalog.to_snapshot();
let history_before = (s.undo.len(), s.redo.len(), s.commits());
// Public command, real filesystem: its no-op path drains the naturally retained repair.
let retry = s.execute("photo.rename", &json!({"ids": [2], "template": "{name}"}));
let catalog_after = s.catalog.to_snapshot();
let history_after = (s.undo.len(), s.redo.len(), s.commits());
let repaired = queues(&s).is_empty();
let old_paths_absent = !b.exists() && !first.exists();
let files = [&a, &second, &first_packet, &second_packet].map(|p| std::fs::read(p).map_err(|e| e.kind()));
let retained = std::fs::read(&shared).map_err(|e| e.kind());
drop(s);
std::fs::remove_dir_all(&dir).unwrap();
assert!(!dir.exists(), "fixture cleanup must precede the qualifying assertion");
assert_eq!(retry.unwrap()["renamed"], 0);
assert_eq!(catalog_after, catalog_before, "repair must preserve both catalog sources");
assert_eq!(history_after, history_before);
assert!(repaired && old_paths_absent);
assert_eq!(files, [Ok(jpeg), Ok(png), Ok(bytes.clone()), Ok(bytes.clone())]);
assert_eq!(retained, Ok(bytes), "queued repair took shared XMP from the photo restored by batch rollback");
}
@@ -0,0 +1,464 @@
//! Native files and genuine Session entry points: packet ownership survives partial renames.
use super::*;
use crate::rename::{MoveFs, RealFs};
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
struct Faults(Vec<PathBuf>);
impl MoveFs for Faults {
fn exists(&self, p: &Path) -> bool {
RealFs.exists(p)
}
fn is_file(&self, p: &Path) -> bool {
RealFs.is_file(p)
}
fn same_file(&self, a: &Path, b: &Path) -> bool {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.0.iter().any(|p| p == a) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move"))
} else {
RealFs.rename_no_replace(a, b)
}
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
RealFs.copy_no_replace(a, b)
}
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
RealFs.remove_file(p)
}
fn stem_sibling(&self, p: &Path) -> Option<String> {
RealFs.stem_sibling(p)
}
}
fn packet(marker: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:fixture="urn:lightcraft:provenance-test" xmp:Rating="5" fixture:Marker="{marker}"/></rdf:RDF></x:xmpmeta>"#
)
}
fn path(photo: &Path, style: SidecarNaming, upper: bool) -> PathBuf {
let p = sidecar_path(&photo.to_string_lossy(), style);
if upper { p.with_extension("XMP") } else { p }
}
fn queued(s: &Session) -> Vec<crate::rename::SidecarMove> {
s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|e| &e.retry_sidecars)).cloned().collect()
}
fn session(photo: &Path) -> Session {
let mut s = Session::new();
let p = Photo::new(PhotoId(1), Source::File { path: photo.to_string_lossy().into_owned() }, "a.jpg", "JPEG", 4, 3, "");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.selection = crate::Selection::single(PhotoId(1));
s
}
fn save(s: &mut Session) -> serde_json::Value {
s.execute("photo.saveMetadataToFile", &json!({})).unwrap()
}
struct Partial {
s: Session,
dir: PathBuf,
a: PathBuf,
b: PathBuf,
companion: PathBuf,
source: PathBuf,
target: PathBuf,
}
fn partial(style: SidecarNaming, upper_stem: bool, upper_full: bool, shared: bool) -> Partial {
// one folder per fixture: tests run in parallel, and each removes its folder at the end
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = crate::tests_xmp::temp_dir(&format!("sidecar-provenance-{n}"));
let a = dir.join("a.jpg");
let b = dir.join("Trip.jpg");
std::fs::write(&a, b"tiny physical photo; rename does not decode").unwrap();
let stem_a = path(&a, SidecarNaming::Stem, upper_stem);
let full_a = path(&a, SidecarNaming::Full, upper_full);
let stem_b = path(&b, SidecarNaming::Stem, upper_stem);
let full_b = path(&b, SidecarNaming::Full, upper_full);
std::fs::write(&stem_a, packet("observed-stem")).unwrap();
std::fs::write(&full_a, packet("observed-full")).unwrap();
if shared {
std::fs::write(dir.join("a.CR3"), b"shared physical sibling").unwrap();
}
let mut s = session(&a);
s.xmp.naming = style;
let rollback = style == SidecarNaming::Full;
let faults = Faults(vec![full_a.clone(), if rollback { stem_b.clone() } else { b.clone() }]);
let plans = s.plan_rename_with(&faults, &[PhotoId(1)], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&faults, &plans)?}))).is_err());
assert_eq!(queued(&s).len(), 1);
let (companion, source, target) = if rollback { (full_a, stem_b, stem_a) } else { (stem_b, full_a, full_b) };
Partial { s, dir, a, b, companion, source, target }
}
fn replace(p: &Path, held: &Path, in_place: bool) -> (Vec<u8>, Vec<u8>) {
let original = std::fs::read(p).unwrap();
#[cfg(unix)]
let before = {
use std::os::unix::fs::MetadataExt;
let m = std::fs::metadata(p).unwrap();
(m.dev(), m.ino())
};
if in_place {
std::fs::write(held, &original).unwrap();
} else {
std::fs::rename(p, held).unwrap();
}
let foreign = packet(&"f".repeat(original.len() - packet("").len())).into_bytes();
assert_eq!(original.len(), foreign.len(), "content changes must be detected even at the same length");
assert_ne!(original, foreign);
assert!(parse_sidecar(std::str::from_utf8(&foreign).unwrap(), crate::crs::Target::Rendered).is_ok());
std::fs::write(p, &foreign).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let m = std::fs::metadata(p).unwrap();
assert_eq!(before == (m.dev(), m.ino()), in_place, "exercise replacement and unchanged-inode edits separately");
}
(original, foreign)
}
#[test]
fn replaced_and_in_place_companions_block_save_autowrite_retry_and_file_history() {
for style in [SidecarNaming::Stem, SidecarNaming::Full] {
for upper_stem in [false, true] {
for upper_full in [false, true] {
for in_place in [false, true] {
let mut f = partial(style, upper_stem, upper_full, false);
let held = f.dir.join("original-held.xmp");
let (original, foreign) = replace(&f.companion, &held, in_place);
let queue = queued(&f.s);
let source = std::fs::read(&f.source).unwrap();
let before = f.s.catalog.photo(PhotoId(1)).unwrap().source.clone();
f.s.xmp.auto_write = true;
f.s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap();
assert_eq!(queued(&f.s), queue, "AutoWrite must retain the exact repair");
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
let saved = save(&mut f.s);
assert!(saved["written"].as_array().unwrap().is_empty());
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("changed since"));
assert_eq!(queued(&f.s), queue);
assert_eq!(std::fs::read(&f.source).unwrap(), source);
assert!(!f.target.exists());
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
let plans = f.s.plan_rename(&[PhotoId(1)], if style == SidecarNaming::Stem { "Trip" } else { "a" }, 1);
assert!(f.s.apply_rename(&plans).is_err());
// Metadata history remains available while physical ownership is unresolved.
f.s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
assert!(f.s.undo_step().is_err(), "the real rename Undo must reject the foreign companion");
assert_eq!(queued(&f.s), queue);
assert_eq!(f.s.catalog.photo(PhotoId(1)).unwrap().source, before);
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
assert_eq!(std::fs::read(&held).unwrap(), original);
// Restoring the observed content resolves ownership and keeps the positive path.
std::fs::write(&f.companion, &original).unwrap();
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(queued(&f.s).is_empty());
assert_eq!(std::fs::read(&f.target).unwrap(), source);
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
}
}
}
}
}
#[test]
fn failed_redo_and_discarded_or_pruned_history_keep_companion_provenance() {
for owner in ["redo", "discarded", "pruned"] {
let mut f = partial(SidecarNaming::Stem, false, false, false);
if owner == "pruned" {
for _ in 0..1000 {
f.s.commit("Unrelated retention step", Op::Batch { ops: Vec::new() }).unwrap();
}
assert_eq!(f.s.pending_sidecar_repairs.len(), 1);
} else {
f.s.undo_step().unwrap();
let full_a = path(&f.a, SidecarNaming::Full, false);
assert!(f.s.redo_step_with(&Faults(vec![full_a, f.b.clone()])).is_err());
assert_eq!(f.s.redo.iter().map(|e| e.retry_sidecars.len()).sum::<usize>(), 1);
if owner == "discarded" {
f.s.commit("Discard failed redo", Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
assert_eq!(f.s.pending_sidecar_repairs.len(), 1);
assert!(f.s.redo.is_empty());
}
}
let held = f.dir.join("original-held.xmp");
let (original, foreign) = replace(&f.companion, &held, false);
let queue = queued(&f.s);
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
if owner == "redo" {
assert!(f.s.redo_step().is_err());
}
assert_eq!(queued(&f.s), queue);
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
std::fs::write(&f.companion, &original).unwrap();
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(queued(&f.s).is_empty());
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
}
}
fn two_repairs() -> Option<Partial> {
let mut f = partial(SidecarNaming::Stem, false, false, false);
// Resolve the initial fixture, then create a new genuine partial rename with two packets.
f.s.undo_step().unwrap();
let upper_a = path(&f.a, SidecarNaming::Full, true);
std::fs::write(&upper_a, packet("observed-upper-full")).unwrap();
let full_a = path(&f.a, SidecarNaming::Full, false);
if RealFs.same_file(&upper_a, &full_a) {
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
return None; // This case requires two physically distinct case spellings.
}
let fs = Faults(vec![upper_a, full_a.clone(), f.b.clone()]);
let plans = f.s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert!(f.s.apply_rename_with(&fs, &plans).is_err());
assert_eq!(queued(&f.s).len(), 2);
f.source = full_a;
Some(f)
}
#[test]
fn absent_peer_destinations_are_not_owned_and_retirement_records_only_completed_packets() {
let Some(mut f) = two_repairs() else { return };
let future = path(&f.b, SidecarNaming::Full, false);
let upper = path(&f.b, SidecarNaming::Full, true);
let foreign = packet("foreign-at-never-created-destination").into_bytes();
std::fs::write(&future, &foreign).unwrap();
let queue = queued(&f.s);
let original_companion = std::fs::read(&f.companion).unwrap();
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
assert_eq!(queued(&f.s), queue);
assert!(!upper.exists(), "a foreign peer must be rejected before the first repair retires");
assert_eq!(std::fs::read(&future).unwrap(), foreign);
assert_eq!(std::fs::read(&f.companion).unwrap(), original_companion);
std::fs::remove_file(&future).unwrap();
assert!(f.s.repair_sidecars_with(&Faults(vec![f.source.clone()])).is_err());
assert_eq!(queued(&f.s).len(), 1);
let held = f.dir.join("retired-original-held.xmp");
let (observed_upper, replaced) = replace(&upper, &held, false);
let queue = queued(&f.s);
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
assert_eq!(queued(&f.s), queue);
assert_eq!(std::fs::read(&upper).unwrap(), replaced);
assert!(!future.exists());
std::fs::write(&upper, &observed_upper).unwrap();
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(queued(&f.s).is_empty());
assert_eq!(std::fs::read(&upper).unwrap(), observed_upper);
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
}
#[test]
fn own_atomic_shared_packet_writes_advance_retained_versions_and_failures_do_not() {
let mut f = partial(SidecarNaming::Stem, false, false, true);
let shared = path(&f.a, SidecarNaming::Stem, false);
let shared_before = std::fs::read(&shared).unwrap();
let copied_before = std::fs::read(&f.companion).unwrap();
let raw = f.dir.join("a.CR3");
let mut photo = Photo::new(PhotoId(2), Source::File { path: raw.to_string_lossy().into_owned() }, "a.CR3", "CR3", 4, 3, "");
photo.kind = MediaKind::Raw;
photo.meta.title = "Legitimate shared packet write".into();
f.s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
// A missing repair for the moved JPEG must not block a healthy save by its raw sibling.
let held = f.dir.join("unavailable-source.xmp");
std::fs::rename(&f.source, &held).unwrap();
let before = queued(&f.s);
{
let _fault = lightcraft_catalog::safe_file::fail_writes_after(0);
assert!(f.s.save_sidecar(PhotoId(2)).is_err());
}
assert_eq!(queued(&f.s), before, "failed atomic writes must not advance provenance");
assert_eq!(std::fs::read(&shared).unwrap(), shared_before);
f.s.save_sidecar(PhotoId(2)).unwrap();
assert_ne!(std::fs::read(&shared).unwrap(), shared_before);
assert_eq!(std::fs::read(&f.companion).unwrap(), copied_before);
assert_eq!(queued(&f.s).len(), 1);
// External edits are still refused after the application's own atomic replacement.
let shared_written = std::fs::read(&shared).unwrap();
std::fs::write(&shared, packet("foreign-after-our-write")).unwrap();
assert!(f.s.save_sidecar(PhotoId(2)).is_err());
assert_eq!(std::fs::read_to_string(&shared).unwrap(), packet("foreign-after-our-write"));
std::fs::write(&shared, &shared_written).unwrap();
f.s.save_sidecar(PhotoId(2)).unwrap();
std::fs::rename(&held, &f.source).unwrap();
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(queued(&f.s).is_empty(), "own shared writes must not stale the retained repair");
assert_eq!(std::fs::read(&shared).unwrap(), shared_written);
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
}
#[test]
fn replaced_copy_keeps_its_shared_original_and_repair_source_byte_identical() {
let mut f = partial(SidecarNaming::Stem, true, true, true);
let shared = path(&f.a, SidecarNaming::Stem, true);
let shared_before = std::fs::read(&shared).unwrap();
let source = std::fs::read(&f.source).unwrap();
let held = f.dir.join("copied-original-held.xmp");
let (original, foreign) = replace(&f.companion, &held, false);
let queue = queued(&f.s);
f.s.xmp.auto_write = true;
f.s.execute("photo.setMeta", &json!({"title": "Fresh"})).unwrap();
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
assert_eq!(queued(&f.s), queue);
assert_eq!(std::fs::read(&f.companion).unwrap(), foreign);
assert_eq!(std::fs::read(&shared).unwrap(), shared_before);
assert_eq!(std::fs::read(&f.source).unwrap(), source);
std::fs::write(&f.companion, &original).unwrap();
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(queued(&f.s).is_empty());
assert_eq!(std::fs::read(&shared).unwrap(), shared_before);
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
}
#[test]
fn changed_separated_sources_are_not_moved_or_retired() {
for style in [SidecarNaming::Stem, SidecarNaming::Full] {
let mut f = partial(style, false, true, false);
let held = f.dir.join("source-original-held.xmp");
let (original, foreign) = replace(&f.source, &held, true);
let queue = queued(&f.s);
let companion = std::fs::read(&f.companion).unwrap();
assert!(!save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(f.s.repair_sidecars_with(&RealFs).is_err());
assert_eq!(queued(&f.s), queue);
assert_eq!(std::fs::read(&f.source).unwrap(), foreign);
assert_eq!(std::fs::read(&f.companion).unwrap(), companion);
assert!(!f.target.exists());
std::fs::write(&f.source, &original).unwrap();
assert!(save(&mut f.s)["failed"].as_array().unwrap().is_empty());
assert!(queued(&f.s).is_empty());
drop(f.s);
std::fs::remove_dir_all(f.dir).unwrap();
}
}
#[test]
fn detached_history_entry_is_checked_before_unrelated_global_retirement() {
// A private-method contract, not a UI reproduction: application reachability of two
// simultaneous repair owners is unproven. Native move failures and follow_stuck seed
// both records; the second owner's normal apply_rename preflight is deliberately bypassed.
struct NativePartial {
id: PhotoId,
original: PathBuf,
moved: PathBuf,
stem: PathBuf,
full: PathBuf,
companion: PathBuf,
target: PathBuf,
}
let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract");
let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]);
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
let mut s = Session::new();
s.xmp.naming = SidecarNaming::Stem;
let mut native = Vec::new();
for (i, name) in ["first", "second"].into_iter().enumerate() {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("a.png");
let moved = dir.join("Trip.png");
std::fs::write(&original, &png).unwrap();
let stem = path(&original, SidecarNaming::Stem, false);
let full = path(&original, SidecarNaming::Full, true);
for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] {
std::fs::write(p, packet(&marker)).unwrap();
assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5));
}
let id = PhotoId(i as u64 + 1);
let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, "");
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
let fs = Faults(vec![full.clone(), moved.clone()]);
if i == 0 {
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
assert!(s.apply_rename_with(&fs, &plans).is_err());
} else {
// Register the genuine second partial move without draining the first repair.
let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())];
let err = crate::rename::move_all(&fs, &moves).unwrap_err();
assert_eq!(err.stuck, moves);
assert_eq!(err.pending_sidecars.len(), 1);
let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() };
s.follow_stuck(&op, err, true);
}
assert_eq!(s.undo.len(), i + 1);
assert_eq!(queued(&s).len(), i + 1);
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() });
let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), (4, 3));
native.push(NativePartial {
id,
original,
companion: path(&moved, SidecarNaming::Stem, false),
target: path(&moved, SidecarNaming::Full, true),
moved,
stem,
full,
});
}
let first = &native[0];
let second = &native[1];
let first_stem = std::fs::read(&first.companion).unwrap();
let full_packets: Vec<_> = native.iter().map(|f| std::fs::read(&f.full).unwrap()).collect();
let held = root.join("observed-second-companion.xmp");
let (observed, foreign) = replace(&second.companion, &held, false);
let mut entry = s.undo.pop().unwrap(); // Undo/Redo call this method with their entry detached.
let retained = queued(&s);
let detached = entry.retry_sidecars.clone();
assert_eq!((retained.len(), detached.len()), (1, 1));
let sources: Vec<_> = native.iter().map(|f| s.catalog.photo(f.id).unwrap().source.clone()).collect();
let log_len = s.pending_log.len();
let mut packets: Vec<_> = native.iter().flat_map(|f| [&f.moved, &f.full, &f.companion]).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
packets.push((held.clone(), std::fs::read(&held).unwrap()));
assert!(!first.target.exists() && !second.target.exists());
let err = s.apply_with_files(&mut entry, &RealFs).unwrap_err();
assert!(err.to_string().contains("changed since"), "{err}");
assert_eq!(std::fs::read(&second.companion).unwrap(), foreign);
println!(
"detached history preflight contract: retained_before={}, retained_after={}, detached_before={}, detached_after={}, first_target_exists={}",
retained.len(),
queued(&s).len(),
detached.len(),
entry.retry_sidecars.len(),
first.target.exists()
);
assert_eq!(queued(&s), retained, "reject the detached owner before any unrelated global repair retires");
assert_eq!(entry.retry_sidecars, detached);
assert_eq!(s.pending_log.len(), log_len);
for (f, source) in native.iter().zip(&sources) {
assert_eq!(&s.catalog.photo(f.id).unwrap().source, source);
assert!(!f.original.exists() && !f.target.exists());
}
for (p, bytes) in &packets {
assert_eq!(std::fs::read(p).unwrap(), *bytes, "rejection must preserve {} byte for byte", p.display());
}
// Resolving the changed content permits the same real method to repair and undo the move.
std::fs::write(&second.companion, &observed).unwrap();
let inverse = s.apply_with_files(&mut entry, &RealFs).unwrap();
assert!(queued(&s).is_empty() && entry.retry_sidecars.is_empty());
assert_eq!(s.catalog.photo(first.id).unwrap().source, sources[0]);
assert_eq!(s.catalog.photo(second.id).unwrap().source, Source::File { path: second.original.to_string_lossy().into_owned() });
assert_eq!(s.file_moves(&inverse), vec![(second.original.to_string_lossy().into_owned(), second.moved.to_string_lossy().into_owned())]);
assert_eq!(std::fs::read(&first.moved).unwrap(), png);
assert_eq!(std::fs::read(&second.original).unwrap(), png);
assert_eq!(std::fs::read(&first.companion).unwrap(), first_stem);
assert_eq!(std::fs::read(&first.target).unwrap(), full_packets[0]);
assert_eq!(std::fs::read(&second.stem).unwrap(), observed);
assert_eq!(std::fs::read(&second.full).unwrap(), full_packets[1]);
assert_eq!(std::fs::read(&held).unwrap(), observed);
assert!(!first.original.exists() && !first.full.exists());
assert!(!second.moved.exists() && !second.companion.exists() && !second.target.exists());
drop(s);
std::fs::remove_dir_all(root).unwrap();
}
@@ -0,0 +1,210 @@
//! Real Session/serializer regressions for saves that overlap retained physical repairs.
use super::*;
use crate::rename::{MoveFs, RealFs};
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
struct PartialRename {
sidecar: PathBuf,
photo: PathBuf,
}
impl MoveFs for PartialRename {
fn exists(&self, path: &Path) -> bool {
RealFs.exists(path)
}
fn is_file(&self, path: &Path) -> bool {
RealFs.is_file(path)
}
fn same_file(&self, a: &Path, b: &Path) -> bool {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if a == self.sidecar || a == self.photo {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault"))
} else {
RealFs.rename_no_replace(a, b)
}
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if a == self.sidecar {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault"))
} else {
RealFs.copy_no_replace(a, b)
}
}
fn remove_file(&self, path: &Path) -> std::io::Result<()> {
RealFs.remove_file(path)
}
fn stem_sibling(&self, path: &Path) -> Option<String> {
RealFs.stem_sibling(path)
}
}
const FOREIGN: &[u8] = br#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:fixture="urn:lightcraft:repair-test" xmp:Rating="2" fixture:Marker="keep-me"/></rdf:RDF></x:xmpmeta>"#;
fn png(path: &Path) {
let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]);
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn queued(s: &Session) -> Vec<crate::rename::SidecarMove> {
s.pending_sidecar_repairs.iter().chain(s.undo.iter().chain(&s.redo).flat_map(|entry| entry.retry_sidecars.iter())).cloned().collect()
}
#[test]
fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_switch() {
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for upper in [false, true] {
for switch in [false, true] {
let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}"));
let a = root.join("a.png");
let b = root.join("Trip.png");
png(&a);
let mut old = sidecar_path(&a.to_string_lossy(), naming);
if upper {
old = old.with_extension("XMP");
}
std::fs::write(&old, FOREIGN).unwrap();
let mut s = Session::new().with_fs();
s.open_library(root.join("library-a"), false).unwrap();
s.execute("library.import", &json!({"paths": [a]})).unwrap();
let mut id = s.catalog.photos().next().unwrap().id;
s.selection = crate::Selection::single(id);
s.undo.clear();
let fs = PartialRename { sidecar: old.clone(), photo: b.clone() };
let plans = s.plan_rename_with(&fs, &[id], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(queued(&s).len(), 1);
assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() });
let pending = queued(&s);
let held = root.join("temporarily-unavailable.xmp");
std::fs::rename(&old, &held).unwrap();
if switch {
s.close_library().unwrap();
s.open_library(root.join("library-b"), false).unwrap();
assert_eq!(s.pending_sidecar_repairs, pending);
s.execute("library.import", &json!({"paths": [b]})).unwrap();
id = s.catalog.photos().next().unwrap().id;
s.selection = crate::Selection::single(id);
}
s.xmp.naming = save_naming;
s.xmp.auto_write = true;
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
let preferred = sidecar_path(&b.to_string_lossy(), save_naming);
assert!(!preferred.exists() && !preferred.with_extension("XMP").exists());
assert!(!pending[0].to.exists());
assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite");
let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
assert!(saved["written"].as_array().unwrap().is_empty());
assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair"));
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
assert_eq!(queued(&s), pending);
// A missing sidecar for another photo does not prevent this healthy save.
let healthy = root.join("healthy.png");
png(&healthy);
let healthy_id = PhotoId(999);
s.commit(
"Healthy Photo",
Op::AddPhoto {
photo: Box::new(Photo::new(
healthy_id,
Source::File { path: healthy.to_string_lossy().into_owned() },
"healthy.png",
"PNG",
4,
3,
"",
)),
},
)
.unwrap();
s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap();
let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap();
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save"));
assert_eq!(queued(&s), pending);
// An existing destination is never overwritten and the original packet remains.
std::fs::rename(&held, &old).unwrap();
let destination = pending[0].to.clone();
let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes();
let collision_path = if upper { preferred.clone() } else { destination.clone() };
std::fs::write(&collision_path, &collision).unwrap();
s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap();
assert_eq!(std::fs::read(&old).unwrap(), FOREIGN);
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
assert_eq!(queued(&s), pending);
s.selection = crate::Selection::single(id);
assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1);
assert_eq!(std::fs::read(&collision_path).unwrap(), collision);
std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision
// The next successful AutoWrite runs the real repair and production XMP merge.
s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap();
assert!(queued(&s).is_empty());
assert!(!old.exists());
let packet = std::fs::read_to_string(&destination).unwrap();
assert!(packet.contains("fixture:Marker=\"keep-me\""));
let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap();
assert_eq!(parsed.rating, Some(4));
assert_eq!(parsed.title.as_deref(), Some("Final title"));
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
if upper {
assert!(!preferred.exists(), "merge uses the repaired uppercase packet");
}
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
s.execute(step, &json!({})).unwrap();
let packet = std::fs::read_to_string(&destination).unwrap();
assert!(packet.contains("fixture:Marker=\"keep-me\""));
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title));
}
drop(s);
std::fs::remove_dir_all(root).unwrap();
}
}
}
}
}
#[test]
fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() {
let root = crate::tests_xmp::temp_dir("repair-save-shared-stem");
let raw = root.join("old.CR3");
let moved = root.join("Trip.CR3");
let jpeg = root.join("Trip.jpg");
std::fs::write(&raw, b"physical fixture; catalogued without decoding").unwrap();
png(&jpeg);
let old = sidecar_path(&raw.to_string_lossy(), SidecarNaming::Stem);
std::fs::write(&old, FOREIGN).unwrap();
let mut s = Session::new();
let raw_id = PhotoId(1);
let jpeg_id = PhotoId(2);
let mut photo = Photo::new(raw_id, Source::File { path: raw.to_string_lossy().into_owned() }, "old.CR3", "CR3", 4, 3, "");
photo.kind = MediaKind::Raw;
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
s.catalog
.apply(Op::AddPhoto {
photo: Box::new(Photo::new(jpeg_id, Source::File { path: jpeg.to_string_lossy().into_owned() }, "Trip.jpg", "JPEG", 4, 3, "")),
})
.unwrap();
let fs = PartialRename { sidecar: old.clone(), photo: moved };
let plans = s.plan_rename_with(&fs, &[raw_id], "Trip", 1);
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
let pending = queued(&s);
assert_eq!(pending.len(), 1);
let held = root.join("temporarily-unavailable.xmp");
std::fs::rename(&old, &held).unwrap();
assert_eq!(s.sidecar_naming(jpeg_id), SidecarNaming::Full);
s.selection = crate::Selection::single(jpeg_id);
s.xmp.auto_write = true;
s.execute("photo.setMeta", &json!({"title": "Healthy JPEG"})).unwrap();
let full = sidecar_path(&jpeg.to_string_lossy(), SidecarNaming::Full);
let packet = std::fs::read_to_string(&full).unwrap();
assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy JPEG"));
assert!(!pending[0].to.exists());
assert_eq!(queued(&s), pending);
assert_eq!(std::fs::read(&held).unwrap(), FOREIGN);
drop(s);
std::fs::remove_dir_all(root).unwrap();
}
+111 -19
View File
@@ -21,6 +21,33 @@ pub fn file_name(p: &Photo) -> String {
format!("{:032x}.lcsp", h.0)
}
/// The default sharded location of a proxy. A two-character hex prefix bounds each
/// folder to about 1/256 of the library's smart previews.
pub fn sharded_file_path(dir: &Path, p: &Photo) -> PathBuf {
let name = file_name(p);
match name.get(..2) {
Some(shard) => dir.join(shard).join(&name),
None => dir.join(&name),
}
}
/// Read an existing proxy in either layout. New proxies use the sharded location; previews
/// made by older releases still load and can be moved with smartPreviewsLocation.
pub fn file_path(dir: &Path, p: &Photo) -> PathBuf {
existing_file_path(dir, sharded_file_path(dir, p))
}
/// Resolve a sharded path to an older flat file when present. Call only off the UI thread:
/// checking a removable drive synchronously could stall drawing.
pub fn existing_file_path(dir: &Path, sharded: PathBuf) -> PathBuf {
if sharded.exists() {
return sharded;
}
let Some(name) = sharded.file_name() else { return sharded };
let legacy = dir.join(name);
if legacy.exists() { legacy } else { sharded }
}
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
const HEADER_MAX: u64 = 4096;
@@ -104,12 +131,45 @@ pub fn check_writable(dir: &Path) -> Result<(), String> {
Ok(())
}
/// The owned proxy files in both layouts. Do not follow symlinked directories or
/// traverse unrelated folders inside a user-selected smart preview directory.
fn files(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut found = Vec::new();
for e in entries.flatten() {
let Ok(kind) = e.file_type() else { continue };
if kind.is_file() && e.path().extension().is_some_and(|ext| ext == "lcsp") {
found.push(e.path()); // legacy layout
} else if kind.is_dir() {
let shard_name = e.file_name();
let Some(shard) = shard_name.to_str() else { continue };
if shard.len() != 2 || !shard.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
continue;
}
let Ok(children) = std::fs::read_dir(e.path()) else { continue };
for child in children.flatten() {
let name = child.file_name();
let Some(name) = name.to_str() else { continue };
let Some(stem) = name.strip_suffix(".lcsp") else { continue };
if stem.len() == 32
&& stem.starts_with(shard)
&& stem.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
&& child.file_type().is_ok_and(|kind| kind.is_file())
{
found.push(child.path());
}
}
}
}
found
}
/// The number of smart previews in `dir` and their total size in bytes.
pub fn stats(dir: &Path) -> (usize, u64) {
let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) };
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "lcsp"))
.fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len())))
files(dir).into_iter().fold((0usize, 0u64), |(count, bytes), path| {
let size = std::fs::metadata(path).map_or(0, |m| m.len());
(count.saturating_add(1), bytes.saturating_add(size))
})
}
/// What to do with the smart previews in the old folder when the location changes.
@@ -140,24 +200,32 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
if what == Existing::Leave {
return (done, failed);
}
let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) };
for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) {
let src = e.path();
for src in files(from) {
let Some(name) = src.file_name() else { continue };
let dst = name
.to_str()
.and_then(|n| n.strip_suffix(".lcsp"))
.filter(|stem| stem.len() == 32 && stem.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()))
.map_or_else(|| to.join(name), |stem| to.join(&stem[..2]).join(name));
let r = match what {
Existing::Discard => std::fs::remove_file(&src),
_ => {
let dst = to.join(e.file_name());
if is_valid(&dst) {
// named by content: the complete copy already there is the same preview
std::fs::remove_file(&src)
} else {
// across drives: copied atomically (never a partial proxy at the new place)
std::fs::rename(&src, &dst).or_else(|_| {
std::fs::read(&src)
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
.and_then(|()| std::fs::remove_file(&src))
})
}
let parent = dst.parent().unwrap_or(to);
// Create only the shard, never missing parent directories on an unplugged drive.
let prepare = if parent.is_dir() { Ok(()) } else { std::fs::create_dir(parent) };
prepare.and_then(|()| {
if is_valid(&dst) {
// named by content: the complete copy already there is the same preview
std::fs::remove_file(&src)
} else {
// across drives: copied atomically (never a partial proxy at the new place)
std::fs::rename(&src, &dst).or_else(|_| {
std::fs::read(&src)
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
.and_then(|()| std::fs::remove_file(&src))
})
}
})
}
};
match r {
@@ -288,6 +356,30 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sharded_and_legacy_proxies_are_counted_and_moved() {
let base = temp("shards");
let from = base.join("from");
let to = base.join("to");
std::fs::create_dir_all(from.join("a1")).unwrap();
std::fs::create_dir(&to).unwrap();
let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp";
let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp";
std::fs::write(from.join(old), b"legacy").unwrap();
std::fs::write(from.join("a1").join(new), b"sharded").unwrap();
std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap();
std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap();
assert_eq!(stats(&from), (2, 13));
let (moved, failed) = migrate(&from, &to, Existing::Move);
assert_eq!((moved, failed), (2, Vec::<String>::new()));
assert_eq!(stats(&from), (0, 0));
assert_eq!(stats(&to), (2, 13));
assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy");
assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded");
assert!(from.join("a1").join("notes.txt").exists());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() {
let base = temp("valid");
+72
View File
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
}
}
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
/// keeps none.
#[test]
fn reset_edits_restores_a_raws_import_defaults() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
let mut s = demo();
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
let name = format!("p{id}.{format}");
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
if raw {
p.as_shot_wb = Some((5200.0, 4.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
}
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
let d = active_dev(&s);
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
}
}
#[test]
fn demo_library_loads() {
let mut s = demo();
@@ -530,6 +557,36 @@ fn crop_drag_keeps_a_locked_ratio_on_every_handle() {
}
}
// Feature: Shift while dragging keeps the current proportions (issue 614)
#[test]
fn crop_drag_keep_ratio_holds_the_shape_with_the_lock_off() {
let mut s = demo();
s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap();
assert!(active_dev(&s).crop.aspect.is_none(), "lock is off");
let r = active_dev(&s).crop.geometry.rect;
let want = r.aspect();
for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] {
s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": true}))
.unwrap();
let g = active_dev(&s).crop.geometry.rect;
assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect());
assert!(active_dev(&s).crop.aspect.is_none(), "the lock toggle is untouched");
}
}
#[test]
fn crop_drag_without_keep_ratio_and_lock_off_is_free() {
let mut s = demo();
s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap();
let r = active_dev(&s).crop.geometry.rect;
let want = r.aspect();
s.execute("crop.drag", &json!({"handle": "right", "from": [0.7, 0.4], "to": [0.9, 0.4], "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap();
assert!((active_dev(&s).crop.geometry.rect.aspect() - want).abs() > 1e-3, "ratio changes freely");
s.execute("crop.drag", &json!({"handle": "right", "from": [0.7, 0.4], "to": [0.9, 0.4], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}))
.unwrap();
assert!((active_dev(&s).crop.geometry.rect.aspect() - want).abs() > 1e-3, "keepRatio false is the same");
}
#[test]
fn crop_drag_rejects_unknown_handles_and_bad_points() {
let mut s = demo();
@@ -1106,3 +1163,18 @@ fn undo_and_redo_show_the_photo_they_change() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.selection, before, "a step over several photos keeps the selection");
}
#[test]
fn hdr_edits_render_with_their_hdr_histogram() {
let mut s = demo();
let id = s.active().unwrap();
let plain = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
assert!(plain.histogram.hdr.is_none(), "SDR edits: the image's histogram");
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
let r = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
let bins = r.histogram.hdr.expect("an HDR edit's histogram");
assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "a fixed scale");
assert!(r.histogram.above_sdr() > 0.0);
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
}
+99
View File
@@ -0,0 +1,99 @@
//! Albums hold library photos (issue #684): browsed (Local) photos are skipped by
//! `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B), which report how
//! many; a Local-only selection is refused and leaves Photo ▸ Add to Album off.
use std::path::{Path, PathBuf};
use lightcraft_catalog::{AlbumId, PhotoId};
use serde_json::json;
use crate::{Selection, Session};
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-albums-local-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn png(p: &Path, seed: u8) {
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
let b =
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap();
std::fs::write(p, b).unwrap();
}
/// A session browsing a folder of two files, one of them added to My Photos: (session, local, library).
fn browsed(tag: &str) -> (Session, PhotoId, PhotoId) {
let dir = temp_dir(tag);
png(&dir.join("a.png"), 1);
png(&dir.join("b.png"), 2);
let mut s = Session::new().with_fs();
s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap();
let mut ids: Vec<PhotoId> = s.catalog.photos().filter(|p| p.local).map(|p| p.id).collect();
ids.sort();
assert_eq!(ids.len(), 2, "both files are browsed");
let (local, library) = (ids[0], ids[1]);
s.execute("photo.addToLibrary", &json!({"ids": [library.0]})).unwrap();
assert!(!s.catalog.photo(library).unwrap().local && s.catalog.photo(local).unwrap().local);
(s, local, library)
}
fn enabled(s: &Session, id: &str) -> bool {
s.commands().into_iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}")).enabled
}
#[test]
fn a_local_only_selection_is_refused_and_leaves_add_to_album_off() {
let (mut s, local, library) = browsed("refused");
let album = AlbumId(s.execute("album.create", &json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap());
s.selection = Selection::single(local);
assert!(!enabled(&s, "album.addPhotos"), "Photo ▸ Add to Album is off for a Local photo");
for (command, params) in [
("album.addPhotos", json!({"id": album.0})),
("album.addPhotos", json!({"id": album.0, "ids": [local.0]})),
("album.toggleTarget", json!({})),
] {
let e = s.execute(command, &params).unwrap_err().to_string();
assert!(e.contains("Local") && e.contains("Add to My Photos"), "{command}: {e}");
}
assert!(s.catalog.album(album).unwrap().photos.is_empty(), "nothing invisible was added");
assert!(s.catalog.quick_collection().is_none(), "no Quick Collection was made for nothing");
// the album is still created from a Local-only selection, just without it
let r = s.execute("album.create", &json!({"name": "Empty", "addSelected": true})).unwrap();
assert_eq!(r["skipped"], 1, "{r}");
assert!(s.catalog.album(AlbumId(r["id"].as_u64().unwrap())).unwrap().photos.is_empty());
// once in My Photos the same photo goes in
s.execute("photo.addToLibrary", &json!({"ids": [local.0]})).unwrap();
assert!(enabled(&s, "album.addPhotos"));
let r = s.execute("album.addPhotos", &json!({"id": album.0})).unwrap();
assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(s.catalog.album(album).unwrap().photos, vec![local]);
let _ = library;
}
#[test]
fn a_mixed_selection_skips_the_local_photos_and_says_how_many() {
let (mut s, local, library) = browsed("mixed");
s.selection = Selection { ids: vec![local, library], active: Some(library) };
assert!(enabled(&s, "album.addPhotos"), "one library photo is enough");
let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap();
let album = AlbumId(r["id"].as_u64().unwrap());
assert_eq!(r["skipped"], 1, "{r}");
assert_eq!(s.catalog.album(album).unwrap().photos, vec![library]);
assert_eq!(s.catalog.album(album).unwrap().cover, Some(library), "the cover is a photo the album shows");
let other = AlbumId(s.execute("album.create", &json!({"name": "Other", "addSelected": false})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("album.addPhotos", &json!({"id": other.0})).unwrap();
assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(1)), "{r}");
assert_eq!(s.catalog.album(other).unwrap().photos, vec![library]);
let r = s.execute("album.toggleTarget", &json!({})).unwrap();
assert_eq!((r["added"].as_bool(), r["skipped"].as_u64()), (Some(true), Some(1)), "{r}");
let quick = s.catalog.quick_collection().expect("B made the Quick Collection");
assert_eq!(s.catalog.album(quick).unwrap().photos, vec![library]);
// B again: the library photo is the whole addable selection and it is in, so it comes out
let r = s.execute("album.toggleTarget", &json!({})).unwrap();
assert_eq!(r["added"], false, "{r}");
assert!(s.catalog.album(quick).unwrap().photos.is_empty());
}
+99
View File
@@ -121,3 +121,102 @@ fn targeted_adjustment_on_curve_and_mixer() {
assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0));
assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err());
}
/// Issue #730: on a raw whose camera has measured colour matrices the white-balance commands write Kelvin, mark
/// the scale, resolve the presets to their real temperatures, and turn a value still on the old relative scale into
/// the Custom Kelvin value that renders the same before applying an edit on top of it.
#[test]
fn white_balance_commands_write_kelvin_on_raws_whose_camera_has_matrices() {
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
use lightcraft_develop::{DevelopSettings, WbMode, WbScale};
let mut s = Session::new();
let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| {
let id = s.catalog.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = camera.into();
p.as_shot_wb = Some(as_shot);
p.develop = std::sync::Arc::new(p.camera_defaults());
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let kelvin = add(&mut s, "a.arw", "SONY ILCE-7M3", (4986.0, -2.0));
let relative = add(&mut s, "b.arw", "SONY ILCE-7M2", (6500.0, 0.0));
let dev = |s: &Session, id| (*s.develop_of(id).unwrap()).clone();
assert_eq!((dev(&s, kelvin).wb.scale, dev(&s, relative).wb.scale), (WbScale::Kelvin, WbScale::Legacy));
// presets are real temperatures on the Kelvin raw, scaled around 6500 K = as shot on the other
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap();
assert_eq!((r["temp"].as_f64(), r["tint"].as_f64()), (Some(6500.0), Some(10.0)));
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Cloudy, 6500.0, 10.0, WbScale::Kelvin));
s.execute("develop.wb", &json!({"mode": "tungsten"})).unwrap();
assert_eq!(dev(&s, kelvin).wb.temp, 2850.0);
s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap();
let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap();
assert!((r["temp"].as_f64().unwrap() - 6500.0 * 6500.0 / 5500.0).abs() < 1e-6, "{r}");
assert_eq!(dev(&s, relative).wb.scale, WbScale::Legacy);
// a slider edit on the Kelvin raw is Kelvin; the stored value says so
s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap();
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
s.execute("develop.set", &json!({"control": "wb.temp", "value": 5200})).unwrap();
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5200.0, -2.0, WbScale::Kelvin));
let info = s.source_info(kelvin);
assert_eq!(lightcraft_engine_effective(&info, &d), (5200.0, -2.0));
// settings from an older catalog on the Kelvin raw: a relative Custom value (6500 = as shot) with no scale
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
(old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
s.set_develop(kelvin, old.clone(), "old").unwrap();
let shown = lightcraft_engine_effective(&info, &old);
let expect = lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0));
assert_eq!(shown, expect);
assert!(shown.0 < 4986.0, "a warm relative shift is a lower Kelvin than as shot: {shown:?}");
// nudging the tint converts the temperature to the Kelvin it rendered as, then applies the nudge
s.execute("develop.adjust", &json!({"control": "wb.tint", "delta": 5})).unwrap();
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin));
assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb);
// a relative preset value from an older catalog becomes a Custom value with its old look, not the preset's Kelvin
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
(old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0);
s.set_develop(kelvin, old.clone(), "old preset").unwrap();
assert_eq!(lightcraft_engine_effective(&info, &old), lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0)));
s.execute("develop.set", &json!({"control": "wb.tint", "value": 0})).unwrap();
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.tint, d.wb.scale), (WbMode::Custom, 0.0, WbScale::Kelvin));
assert!(d.wb.temp != 6500.0 && (d.wb.temp - lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0)).0).abs() < 1e-6);
// a partial without a scale (an old preset, `develop.merge`) keeps the relative meaning on the Kelvin raw
s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 6500.0, "tint": 0.0}}})).unwrap();
let d = dev(&s, kelvin);
assert_eq!(d.wb.scale, WbScale::Legacy);
assert_eq!(lightcraft_engine_effective(&info, &d), (4986.0, -2.0), "6500 / 0 without a scale is as shot");
// Quick Develop on old settings: As Shot from an older catalog (no scale) nudges the Kelvin as-shot white, a
// relative Custom value is converted to the Kelvin it rendered as first (never a Kelvin number read as relative)
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
old.wb.mode = WbMode::AsShot;
s.set_develop(kelvin, old, "old as shot").unwrap();
s.execute("develop.quickAdjust", &json!({"control": "wb.temp", "delta": 100})).unwrap();
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5086.0, -2.0, WbScale::Kelvin));
let mut old = DevelopSettings::for_raw(6500.0, 0.0);
(old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
s.set_develop(kelvin, old, "old custom").unwrap();
s.execute("develop.quickAdjust", &json!({"control": "wb.tint", "delta": 5})).unwrap();
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin));
assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb);
// reset: As Shot, on the Kelvin scale
s.execute("develop.reset", &json!({})).unwrap();
let d = dev(&s, kelvin);
assert_eq!((d.wb.mode, d.wb.scale), (WbMode::AsShot, WbScale::Kelvin));
assert!(!s.catalog.photo(kelvin).unwrap().is_edited());
}
fn lightcraft_engine_effective(info: &lightcraft_pipeline::SourceInfo, d: &lightcraft_develop::DevelopSettings) -> (f64, f64) {
lightcraft_pipeline::local::effective_wb(info, d)
}
+108
View File
@@ -0,0 +1,108 @@
//! Every way of setting the crop angle keeps the crop inside the photo (issue #742): the generic
//! control path (`develop.set` on `crop.angle`, as MCP, the control channel and `lightcraft-cli
//! render --set` use it, plus `develop.adjust`, `develop.quickAdjust`, `develop.resetControl`) gives
//! the same crop as `crop.straighten` (the rotate handle, the Straighten slider, the Angle field)
//! and `crop.set {angle}`.
use lightcraft_geom::{CropGeometry, Rect};
use serde_json::{Value, json};
use crate::Session;
fn geometry(s: &Session) -> CropGeometry {
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
}
fn dims(s: &Session) -> (f64, f64) {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let (w, h) = (p.width as f64, p.height as f64);
if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }
}
/// Run `cmd` from the current state, return the crop it leaves, and undo it.
fn try_cmd(s: &mut Session, cmd: &str, p: Value) -> CropGeometry {
s.execute(cmd, &p).unwrap();
let g = geometry(s);
s.execute("edit.undo", &json!({})).unwrap();
g
}
fn close(a: CropGeometry, b: CropGeometry) -> bool {
let r = |g: CropGeometry| [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1, g.angle];
r(a).iter().zip(r(b)).all(|(x, y)| (x - y).abs() < 1e-9)
}
#[test]
fn every_way_of_setting_the_angle_gives_the_straighten_crop() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
for aspect in [json!("original"), json!("free"), json!("1x1"), json!("16x9"), json!("4x5")] {
s.execute("crop.aspect", &json!({"aspect": aspect})).unwrap();
let start = geometry(&s);
for angle in [-45.0, -30.0, -7.5, 0.5, 2.0, 15.0, 33.3] {
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": angle}));
assert!(want.is_within_image(w, h), "{aspect} {angle}°: straighten fits");
assert_eq!(want.angle, angle);
for (cmd, p) in [
("develop.set", json!({"control": "crop.angle", "value": angle})),
("develop.set", json!({"values": {"crop.angle": angle, "light.exposure": 0.3}})),
("crop.set", json!({"angle": angle})),
("develop.adjust", json!({"control": "crop.angle", "delta": angle - start.angle})),
] {
let got = try_cmd(&mut s, cmd, p.clone());
assert!(close(got, want), "{aspect} {angle}°: {cmd} {p} gave {got:?}, crop.straighten {want:?}");
}
assert!(close(geometry(&s), start), "undo restored the crop");
}
}
}
#[test]
fn a_crop_that_still_fits_is_kept() {
let mut s = Session::with_demo();
s.execute("crop.set", &json!({"rect": [0.3, 0.3, 0.7, 0.7]})).unwrap();
for angle in [-4.0, 1.0, 3.0, 6.0] {
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
let g = geometry(&s);
assert_eq!(g.rect, Rect::new(0.3, 0.3, 0.7, 0.7), "no needless shrink at {angle}°");
assert_eq!(g.angle, angle);
}
}
#[test]
fn quick_develop_reset_and_batch_paths_fit_too() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
let id = s.active().unwrap();
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": 12.0}));
let got = try_cmd(&mut s, "develop.quickAdjust", json!({"control": "crop.angle", "delta": 12.0, "ids": [id.0]}));
assert!(close(got, want), "quickAdjust {got:?} vs {want:?}");
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
let g = geometry(&s);
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
assert!(g.is_within_image(w, h));
}
#[test]
fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
let start = geometry(&s);
let undo = s.undo.len();
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
for angle in [1.0, 4.0, 9.0, 15.0] {
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
assert!(geometry(&s).is_within_image(w, h), "fitted at every step ({angle}°)");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
assert_eq!(s.undo.len(), undo + 1, "the drag is one undo step");
assert_eq!(geometry(&s).angle, 15.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(geometry(&s), start));
}
+46
View File
@@ -720,6 +720,52 @@ fn a_photo_waits_for_its_turn_unless_pictures_are_not_made_on_their_own_or_have_
let _ = std::fs::remove_dir_all(&x.dir);
}
#[test]
fn a_picture_that_appears_while_its_job_runs_is_ready_only_when_the_job_reports() {
// #719: the worker saves the picture, then tears down its thread pool before it reports. A pump in that window
// used to find the file and mark the photo Ready while the session still counted the job as running: Ready and
// busy at once, and the activity row stayed up for a photo that said it was done.
use std::sync::{Mutex, mpsc};
use std::time::Duration;
let mut x = setup("ready-while-running", true);
let id = photo(&x.s);
set_amount(&mut x.s, 80.0);
// the job loads the model afresh and waits in the loader until it is released: a worker that is at work
*x.s.denoise.active.as_ref().unwrap().model.lock().unwrap() = None;
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Mutex::new(release_rx);
let runs = x.runs.clone();
x.s.denoise.loader = Arc::new(move |_, _| {
entered_tx.send(()).map_err(|e| e.to_string())?;
release_rx.lock().unwrap().recv_timeout(Duration::from_secs(10)).map_err(|e| e.to_string())?;
Ok(Arc::new(Dim { runs: runs.clone() }) as Arc<dyn Model>)
});
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }));
assert!(x.s.denoise_busy());
// the picture lands on disk before the worker says so (as it does between its save and its report)
let (product, key) = x.s.denoise_running_product().unwrap();
let mut rgb = lightcraft_raster::Rgb32f::new(192, 160);
rgb.data.fill([0.25, 0.25, 0.25]);
lightcraft_denoise::product::write(&product, &rgb, &key).unwrap();
assert!(lightcraft_denoise::product::is_current(&product, &key));
x.s.denoise.touch(); // the index is also rebuilt from the folder in this window
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }), "the job reports its own picture");
assert_eq!(r["ready"], 0, "{r}");
assert!(x.s.denoise_busy());
// the worker reports: ready and idle in the same pump
release_tx.send(()).unwrap();
let r = pump_until_ready(&mut x.s, 1);
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
assert_eq!(r["running"], Value::Null, "{r}");
assert!(!x.s.denoise_busy());
drop(x.s);
let _ = std::fs::remove_dir_all(x.dir);
}
#[test]
fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_selection() {
let mut x = setup("toggle", false);
+426 -7
View File
@@ -177,7 +177,13 @@ fn prepared_exports_run_on_another_thread() {
/// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an
/// error) gave an all-black export. The export must come from the CPU instead — the same
/// image — with the reason recorded.
/// image — with the reason recorded on the export itself.
///
/// Issue #675: this test runs beside every other engine test, many of them rendering on the GPU.
/// It asks only about its own export (`Exported::gpu_fallback`), never the process-wide
/// `lightcraft_gpu::last_fallback` another thread's render overwrites, and the simulated failure
/// switches the GPU off for this thread alone, so neither the fault nor `reset_failures` reaches
/// the other tests.
#[test]
fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
let mut s = Session::with_demo();
@@ -192,18 +198,26 @@ fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
let gpu = lightcraft_gpu::available();
let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes);
lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork);
let faulted = export_photo(&mut s, id, &o, 1).unwrap().bytes;
let faulted = export_photo(&mut s, id, &o, 1).unwrap();
if gpu {
let why = lightcraft_gpu::last_fallback().unwrap_or_default();
let why = faulted.gpu_fallback.clone().unwrap_or_default();
assert!(why.contains("incomplete"), "{why}");
// the GPU is off for the process now: this export renders on the CPU
// the GPU is off for this thread now: the next export is not even tried on it
assert!(!lightcraft_gpu::available());
assert!(lightcraft_gpu::unavailable_reason().unwrap_or_default().contains("incomplete"), "{:?}", lightcraft_gpu::unavailable_reason());
// the thread's own `inject_fault` stopped only its own GPU use: the others still have it
let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join().unwrap();
assert_eq!(elsewhere, (true, None), "a simulated failure must not switch the GPU off for the other threads");
} else {
assert_eq!(faulted.gpu_fallback, None);
}
let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes;
let cpu = export_photo(&mut s, id, &o, 1).unwrap();
assert_eq!(cpu.gpu_fallback, None, "not tried on the GPU: no fallback to report");
lightcraft_gpu::reset_failures();
assert_eq!(lightcraft_gpu::available(), gpu, "reset_failures gives this thread the GPU back");
let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data;
assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render");
let m = mean(&faulted);
assert!(px(&faulted.bytes) == px(&cpu.bytes), "the fallback is the CPU render");
let m = mean(&faulted.bytes);
assert!(m > 0.02, "not black: mean {m}");
assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}");
}
@@ -440,3 +454,408 @@ fn hdr_avif_export_is_pq_rec2020() {
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
}
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
#[test]
fn remove_location_holds_for_original_and_dng_exports() {
let dir = temp_dir("export-noloc");
let gps =
lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None }), ..Default::default() };
// a raw with no embedded GPS, located in the catalog
let raw = dir.join("Raw.dng");
std::fs::write(&raw, synthetic_dng_with(None, Default::default())).unwrap();
// a JPEG with GPS in its EXIF
let jpg = dir.join("Geo.jpg");
let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 8) as u8, (y * 10) as u8, 90, 255]);
let exif = lightcraft_meta::write_exif(&gps);
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
std::fs::write(&jpg, lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap())
.unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [raw.to_string_lossy(), jpg.to_string_lossy()]})).unwrap();
let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
let (rid, jid) = (id(&s, "Raw.dng"), id(&s, "Geo.jpg"));
s.execute("photo.setMeta", &json!({"ids": [rid.0, jid.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap();
let opts = |f: &str, remove: bool| ExportOptions::from_json(&json!({"format": f, "removeLocation": remove}));
// original raw: the sidecar keeps the location unless it is removed
let side = |e: &crate::export::Exported| lightcraft_meta::parse_xmp(std::str::from_utf8(&e.sidecars[0].1).unwrap()).unwrap().metadata;
let kept = side(&export_photo(&mut s, rid, &opts("original", false), 1).unwrap());
assert!(kept.gps.is_some() && kept.city.is_some());
let gone = side(&export_photo(&mut s, rid, &opts("original", true), 1).unwrap());
assert!(gone.gps.is_none() && gone.city.is_none(), "{gone:?}");
// dng: the embedded packet
let e = export_photo(&mut s, rid, &opts("dng", false), 1).unwrap();
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
let e = export_photo(&mut s, rid, &opts("dng", true), 1).unwrap();
let m = lightcraft_meta::extract(&e.bytes);
assert!(m.gps.is_none() && m.city.is_none(), "{m:?}");
// original JPEG embedding GPS: copied as is normally, refused when the location must go
let e = export_photo(&mut s, jid, &opts("original", false), 1).unwrap();
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap();
assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// An original raw whose own metadata holds GPS is refused under "Remove location info".
#[test]
fn remove_location_refuses_a_raw_that_embeds_gps() {
let dir = temp_dir("export-noloc-raw");
let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 10.0, longitude: 20.0, altitude: None }), ..Default::default() };
let raw = dir.join("Geo.dng");
let bytes = synthetic_dng_with(Some(&lightcraft_meta::write_xmp(&gps, None)), Default::default());
assert!(lightcraft_meta::extract(&bytes).gps.is_some(), "fixture embeds GPS");
std::fs::write(&raw, bytes).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
let id = s.catalog.photos().next().unwrap().id;
let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap();
assert!(err.contains("can't remove the location"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hdr_png_export_is_16_bit_pq_rec2020() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "png", "hdr": true}));
assert!(o.hdr_output());
assert_eq!(o.effective_space(), lightcraft_pipeline::OutputSpace::Rec2020);
// a photo without an HDR edit exports as an ordinary (sRGB, 8-bit) PNG
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
assert!(!sdr.bytes.windows(4).any(|w| w == b"cICP"));
assert_eq!(sdr.bytes[24], 8, "8-bit");
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
let at = hdr.bytes.windows(4).position(|w| w == b"cICP").expect("cICP chunk");
assert_eq!(&hdr.bytes[at + 4..at + 8], &[9, 16, 0, 1], "BT.2020, PQ, RGB, full range");
assert_eq!(hdr.bytes[24], 16, "16-bit");
// the highlights above SDR white are there: MaxCLL beyond the 203 cd/m² of SDR white
let at = hdr.bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI chunk");
let max_cll = u32::from_be_bytes(hdr.bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
assert!(max_cll > 250.0 && max_cll <= 4.0 * 203.0 + 1.0, "MaxCLL {max_cll}");
// HDR output off: the HDR edit exports its SDR rendition
let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap();
assert!(!plain.bytes.windows(4).any(|w| w == b"cICP"));
}
#[test]
fn hdr_jxl_export_is_pq_rec2020() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "jxl", "hdr": true, "quality": 90}));
assert!(o.hdr_output());
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
let encoding = |b: &[u8]| {
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(b)).unwrap();
let m = &img.image_header().metadata;
(format!("{:?}", m.colour_encoding), m.bit_depth.bits_per_sample())
};
let (c, bits) = encoding(&hdr.bytes);
assert!(c.contains("Bt2100") && c.contains("Pq"), "{c}");
assert_eq!(bits, 16);
// a photo without an HDR edit exports as an ordinary SDR file
let (c, _) = encoding(&sdr.bytes);
assert!(!c.contains("Pq"), "{c}");
}
#[test]
fn jxl_export_writes_metadata() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 120, "format": "jxl", "quality": 80}));
let e = export_photo(&mut s, id, &o, 1).unwrap();
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
assert_eq!(d.width.max(d.height), 120);
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("Golden horizon")), "title in the XMP box");
assert!(d.exif.is_some(), "EXIF box");
}
/// Issue #236: Add to Apple Photos after an export. A fake runner stands in for osascript: tests
/// never start it, nor Photos.
#[cfg(not(target_arch = "wasm32"))]
mod apple_photos {
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use super::disk_batch;
use crate::Session;
use crate::apple_photos::{Imports, MARKER, Output, Runner};
use crate::export::{AfterExport, Destination, ExportOptions};
use crate::tests_xmp::temp_dir;
type Calls = Arc<Mutex<Vec<Vec<String>>>>;
/// Records the arguments; Photos "imports" the first `keep` files (all: `usize::MAX`).
fn fake(keep: usize) -> (Runner, Calls) {
let calls: Calls = Arc::default();
let c = calls.clone();
let r: Runner = Arc::new(move |args: &[String], _| {
c.lock().unwrap().push(args.to_vec());
let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
let ids: Vec<String> = (0..files.min(keep)).map(|i| format!("ID-{i}/L0/001")).collect();
Ok(Output { status: Some(0), stdout: ids.join("\n") + "\n", stderr: String::new() })
});
(r, calls)
}
/// The paths after the album: the files Photos was asked to import.
fn imported_paths(args: &[String]) -> Vec<String> {
args[args.iter().position(|a| a == MARKER).unwrap() + 2..].to_vec()
}
fn export_two(s: &mut Session, tag: &str) -> Vec<Value> {
let dir = temp_dir(tag);
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
let o = ExportOptions::from_json(&json!({"longEdge": 48, "naming": "{name}-{seq}"}));
let to = Destination { dir: dir.to_string_lossy().into(), exact: None };
let mut files = Vec::new();
for id in ids {
files.extend(disk_batch(s, id, &o, &to).unwrap());
}
files
}
#[test]
fn exported_files_go_to_photos_in_order() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-flow");
let written: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
assert_eq!(written.len(), 2);
let (runner, calls) = fake(usize::MAX);
let imports = Imports::default();
let after = AfterExport::apple_photos(runner.clone(), " Trip \"2026\"\n ", &imports).unwrap();
// a skipped file and a failed photo are not sent
let mut results = files.clone();
results.insert(1, json!({"skipped": "/elsewhere/x.jpg"}));
results.push(json!({"photo": 9, "file": "y", "error": "no source"}));
let out = after.run(&results, false).unwrap();
assert_eq!(out["imported"], 2, "{out}");
assert_eq!(out["requested"], 2);
assert_eq!(out["ids"], json!(["ID-0/L0/001", "ID-1/L0/001"]));
assert_eq!(out["album"], "Trip \"2026\"");
assert!(out.get("warning").is_none());
let args = calls.lock().unwrap()[0].clone();
assert_eq!(imported_paths(&args), written, "the files written, in order, as whole arguments");
assert!(written.iter().all(|p| std::path::Path::new(p).is_absolute()));
assert_eq!(args[args.iter().position(|a| a == MARKER).unwrap() + 1], "Trip \"2026\"");
// a cancelled export adds nothing
let out = AfterExport::apple_photos(runner.clone(), "", &imports).unwrap().run(&files, true).unwrap();
assert!(out["skipped"].as_str().unwrap().contains("cancelled"));
assert_eq!(calls.lock().unwrap().len(), 1);
// Photos took one of the two: said so, not an error
let (runner, _) = fake(1);
let out = AfterExport::apple_photos(runner, "", &Imports::default()).unwrap().run(&files, false).unwrap();
assert_eq!((out["imported"].as_u64(), out["requested"].as_u64()), (Some(1), Some(2)));
assert!(out["warning"].as_str().unwrap().contains("1 of 2"), "{out}");
assert!(out["album"].is_null());
// permission refused: the outcome says where to allow it; the files stay written
let denied: Runner = Arc::new(|_: &[String], _| {
Ok(Output {
status: Some(1),
stdout: String::new(),
stderr: "execution error: Not authorized to send Apple events to Photos. (-1743)".into(),
})
});
let out = AfterExport::apple_photos(denied, "", &Imports::default()).unwrap().run(&files, false).unwrap();
assert!(out["error"].as_str().unwrap().contains("Privacy & Security › Automation"), "{out}");
assert!(written.iter().all(|p| std::path::Path::new(p).is_file()));
// nothing asked: nothing done
assert!(AfterExport::default().is_none());
assert_eq!(AfterExport::default().run(&files, false), None);
}
#[test]
fn photos_is_refused_before_anything_is_written_when_it_cannot_be_reached() {
let mut s = Session::with_demo();
assert!(s.apple_photos.is_none(), "only with_fs installs the runner");
let on = ExportOptions { add_to_photos: true, ..Default::default() };
let e = s.after_export(&on).err().unwrap();
if cfg!(target_os = "macos") {
assert!(e.contains("isn't connected in this session"), "{e}");
} else {
assert_eq!(e, "Apple Photos is only available on macOS");
}
assert!(s.after_export(&ExportOptions::default()).unwrap().is_none());
// with a runner: macOS adds, other platforms still refuse
let (runner, calls) = fake(usize::MAX);
s.apple_photos = Some(runner);
let after = s.after_export(&on);
assert_eq!(after.is_ok(), cfg!(target_os = "macos"), "{:?}", after.as_ref().err());
match after {
Ok(after) => {
let files = export_two(&mut s, "photos-session");
assert_eq!(after.run(&files, false).unwrap()["imported"], 2);
assert_eq!(calls.lock().unwrap().len(), 1);
}
Err(e) => assert!(e.contains("only available on macOS"), "{e}"),
}
}
#[test]
fn the_add_to_photos_command_needs_a_runner_and_checks_its_input() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-cmd");
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
let info = |s: &Session| s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap();
// no runner: listed, disabled, with the reason
let i = info(&s);
assert!(!i.enabled);
let why = i.disabled_reason.unwrap();
assert!(why.contains(if cfg!(target_os = "macos") { "isn't connected" } else { "only available on macOS" }), "{why}");
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
assert!(e.contains("Apple Photos"), "{e}");
let (runner, calls) = fake(usize::MAX);
s.apple_photos = Some(runner);
assert!(info(&s).enabled);
// no window: it waits for Photos by default
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Läufe/日本 \\ \"q\""})).unwrap();
assert_eq!((r["imported"].as_u64(), r["running"].as_bool(), r["job"].as_u64()), (Some(2), Some(false), Some(1)), "{r}");
assert_eq!(r["album"], "Läufe/日本 \\ \"q\"");
assert_eq!(imported_paths(&calls.lock().unwrap()[0]), paths);
// bad input never reaches the runner (paths from the temp folder: absolute on every platform)
let missing = std::env::temp_dir().join("lc-photos-nonexistent").join("a.jpg").to_string_lossy().into_owned();
for (p, msg) in [
(json!({}), "missing `paths`"),
(json!({"paths": "a.jpg"}), "missing `paths`"),
(json!({"paths": [1]}), "array of file paths"),
(json!({"paths": []}), "no files"),
(json!({"paths": ["relative.jpg"]}), "absolute"),
(json!({"paths": [missing]}), "no such file"),
(json!({"paths": paths, "album": 3}), "`album` must be a string"),
(json!({"paths": paths, "wait": "yes"}), "`wait` must be true or false"),
(json!({"paths": paths, "albm": "x"}), "unknown parameter `albm`"),
] {
let e = s.execute("export.addToPhotos", &p).unwrap_err().to_string();
assert!(e.contains(msg), "{p}: {e}");
}
assert_eq!(calls.lock().unwrap().len(), 1);
// not journaled: replaying actions must not import again
assert!(!s.journal.iter().any(|(id, _)| id == "export.addToPhotos"));
}
/// The desktop app answers control requests on its UI thread: `export.addToPhotos` must not
/// wait there for Photos (minutes, or a permission prompt). It returns the running job; the
/// outcome comes from `export.photosImports`; a second import, or an export asking for Photos,
/// is refused until it ends, so a retry never adds the files twice.
#[test]
fn a_slow_photos_import_runs_in_the_background() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-slow");
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
let (go, wait) = std::sync::mpsc::channel::<()>();
let wait = Mutex::new(wait);
let runner: Runner = Arc::new(move |args: &[String], _| {
// a regression (waiting on the caller's thread) fails after 20 s instead of hanging
let _ = wait.lock().unwrap().recv_timeout(std::time::Duration::from_secs(20));
let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
Ok(Output { status: Some(0), stdout: (0..n).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() })
});
s.apple_photos = Some(runner);
s.apple_photos_background = true; // as in the desktop app
let t0 = std::time::Instant::now();
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Slow"})).unwrap();
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "answered without waiting for Photos");
assert_eq!(r, json!({"job": 1, "running": true, "requested": 2, "album": "Slow"}));
let status = |s: &mut Session| s.execute("export.photosImports", &json!({"job": 1})).unwrap();
assert_eq!(status(&mut s)["running"], true);
// a retry is refused, and says why
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}");
if cfg!(target_os = "macos") {
let on = ExportOptions { add_to_photos: true, ..Default::default() };
assert!(s.after_export(&on).err().unwrap().contains("still adding"), "refused before writing anything");
}
go.send(()).unwrap();
while status(&mut s)["running"] == true {
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the import finishes");
std::thread::sleep(std::time::Duration::from_millis(5));
}
let done = status(&mut s);
assert_eq!((done["imported"].as_u64(), done["album"].as_str()), (Some(2), Some("Slow")), "{done}");
let all = s.execute("export.photosImports", &json!({})).unwrap();
assert_eq!(all["imports"].as_array().map(Vec::len), Some(1));
assert!(s.execute("export.photosImports", &json!({"job": 7})).unwrap_err().to_string().contains("no import 7"));
// the window's thread never waits for Photos: `wait: true` is refused here, before any import
let e = s.execute("export.addToPhotos", &json!({"paths": paths, "wait": true})).unwrap_err().to_string();
assert!(e.contains("isn't available in the desktop app"), "{e}");
// finished: the next one may start
go.send(()).unwrap();
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["job"], 2);
}
/// Review of #236: an export adding to Photos reserves the import before it writes its files.
/// While it renders, `export.addToPhotos` is refused, naming the export's job, instead of
/// taking Photos and leaving the export to fail its Photos step after writing.
#[test]
fn an_export_holds_photos_from_before_it_writes() {
let mut s = Session::with_demo();
let (runner, calls) = fake(usize::MAX);
s.apple_photos = Some(runner.clone());
let elsewhere = export_two(&mut s, "photos-hold-other");
let other: Vec<String> = elsewhere.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
// what Session::after_export does on macOS (it refuses other platforms first)
let after = AfterExport::apple_photos(runner, "Held", &s.apple_photos_imports).unwrap();
let job = s.execute("export.photosImports", &json!({"job": 1})).unwrap();
assert_eq!((job["running"].as_bool(), job["exporting"].as_bool()), (Some(true), Some(true)), "{job}");
let e = s.execute("export.addToPhotos", &json!({"paths": other})).unwrap_err().to_string();
assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}");
if cfg!(target_os = "macos") {
let on = ExportOptions { add_to_photos: true, ..Default::default() };
assert!(s.after_export(&on).err().unwrap().contains("(import 1)"), "a second export is refused before writing");
}
assert!(calls.lock().unwrap().is_empty());
// the export writes its files and hands them over: imported under its own job
let files = export_two(&mut s, "photos-hold");
let out = after.run(&files, false).unwrap();
assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out["album"].as_str()), (Some(1), Some(2), Some("Held")), "{out}");
// then the slot is free
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 2);
// an export that fails (its AfterExport dropped) frees it too
drop(AfterExport::apple_photos(fake(usize::MAX).0, "", &s.apple_photos_imports).unwrap());
assert!(s.apple_photos_imports.running().is_none());
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 4);
}
/// Review of #236: a runner that panics while the command or an export waits for it is that
/// import's error; the slot is freed, so the next import isn't refused as "still running"
/// and a process ending doesn't wait for it.
#[test]
fn a_panicking_runner_never_holds_photos() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-panic");
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
s.apple_photos = Some(Arc::new(|_: &[String], _| panic!("runner exploded")));
// the command (no window: it waits)
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
assert!(e.contains("stopped unexpectedly"), "{e}");
assert!(s.apple_photos_imports.running().is_none());
assert_eq!(s.execute("export.photosImports", &json!({"job": 1})).unwrap()["running"], false);
// an export's reservation
let runner = s.apple_photos.clone().unwrap();
let out = AfterExport::apple_photos(runner, "", &s.apple_photos_imports).unwrap().run(&files, false).unwrap();
assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}");
assert!(s.apple_photos_imports.running().is_none());
assert!(!s.wait_for_photos_imports(), "nothing left to wait for");
// and the next import runs
let (good, _) = fake(usize::MAX);
s.apple_photos = Some(good);
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["imported"], 2);
}
/// The desktop app, lightcraft-cli and the MCP server get the real runner from `with_fs`, on
/// macOS only. (Installed, never run here.)
#[test]
fn with_fs_installs_the_runner_on_macos_only() {
let s = Session::new().with_fs();
assert_eq!(s.apple_photos.is_some(), cfg!(target_os = "macos"));
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
}
}
+29
View File
@@ -584,3 +584,32 @@ fn the_users_own_catalog_adds_models_with_the_same_download_and_install() {
}
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #678: on means something only with a model to run. Turning recognition on with no chosen
/// (installed) recogniser is refused; installing one chooses it and switches recognition on, after
/// which the switch works both ways; removing the last recogniser switches it off again.
#[test]
fn recognition_cannot_be_turned_on_without_a_chosen_model() {
let d = temp("enable-needs-model");
let mut s = session(&d);
for p in [json!({"enabled": true}), json!({})] {
let e = s.execute("faces.enable", &p).unwrap_err().to_string();
assert!(e.contains("recognition model"), "{p}: {e}");
}
assert_eq!(s.execute("faces.enable", &json!({"enabled": false})).unwrap()["enabled"], false, "off is always allowed");
assert_eq!(s.execute("faces.models.list", &json!({})).unwrap()["enabled"], false);
let f = model_file(&d, "m.onnx", 64);
let r = s.execute("faces.models.install", &json!({"path": f, "acknowledged": true})).unwrap();
let id = r["installed"]["id"].as_str().unwrap().to_string();
assert_eq!(r["active"]["enabled"], true, "installing chooses the model and switches recognition on: {r}");
assert_eq!(s.execute("faces.enable", &json!({"enabled": false})).unwrap()["enabled"], false);
assert_eq!(s.execute("faces.enable", &json!({"enabled": true})).unwrap()["enabled"], true);
assert_eq!(s.execute("faces.enable", &json!({})).unwrap()["enabled"], false, "toggles when omitted");
assert_eq!(s.execute("faces.enable", &json!({})).unwrap()["enabled"], true);
// the last recogniser goes: nothing is chosen, so recognition is off and stays off
assert_eq!(s.execute("faces.models.remove", &json!({"id": id})).unwrap()["embedder"], Value::Null);
let l = s.execute("faces.models.list", &json!({})).unwrap();
assert_eq!((l["enabled"].clone(), l["embedder"].clone()), (json!(false), Value::Null), "{l}");
assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err());
let _ = std::fs::remove_dir_all(&d);
}
@@ -0,0 +1,537 @@
//! Filesystem history and retained XMP repair regressions.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Selection, Session};
fn select(s: &mut Session, ids: &[u64], active: u64) {
s.execute("library.select", &json!({"ids": ids, "active": active})).unwrap();
}
fn memory_library(store: &lightcraft_catalog::MemStore) -> Session {
let mut s = Session::new();
s.open_library_in(
crate::library::LibraryStores {
dir: "history-regression".into(),
catalog: Box::new(store.clone()),
files: Box::new(lightcraft_catalog::MemStore::new()),
on_disk: false,
},
false,
)
.unwrap();
s
}
fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool) -> crate::Result<serde_json::Value> {
s.execute_fn(if redo { "edit.redo" } else { "edit.undo" }, |s| {
let label = if redo { s.redo_step_with(fs)? } else { s.undo_step_with(fs)? };
Ok(json!({"label": label}))
})
}
fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) {
let files = fs.listing();
for photo in s.catalog.photos() {
let Source::File { path } = &photo.source else { panic!("synthetic file fixture") };
assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0);
assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str());
}
let replayed = memory_library(store);
assert_eq!(replayed.catalog.to_snapshot(), s.catalog.to_snapshot());
}
fn sidecar_history_fixture(redo: bool) -> (Session, crate::rename::tests::FakeFs, lightcraft_catalog::MemStore) {
let store = lightcraft_catalog::MemStore::new();
let mut s = memory_library(&store);
let fs = crate::rename::tests::FakeFs::new(
false,
&[
("/p/a.jpg", "Photo"),
("/p/a.XMP", "Upper stem edits"),
("/p/a.xmp", "Lower stem edits"),
("/p/a.jpg.XMP", "Upper full-name edits"),
("/p/a.jpg.xmp", "Lower full-name edits"),
("/p/other.jpg", "Other"),
],
);
for (i, path) in ["/p/a.jpg", "/p/other.jpg"].iter().enumerate() {
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
let photo = Photo::new(PhotoId(i as u64 + 1), Source::File { path: (*path).into() }, &name, "JPEG", 16, 16, "");
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
}
s.persist().unwrap();
s.undo.clear();
select(&mut s, &[1], 1);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
if redo {
file_history_step(&mut s, &fs, false).unwrap();
}
select(&mut s, &[2], 2);
(s, fs, store)
}
fn sidecar_history_entry(s: &Session, redo: bool) -> &crate::UndoEntry {
if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() }
}
#[test]
fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal() {
use crate::rename::tests::FakeFs;
use crate::sidecar::{SidecarNaming, sidecar_path};
let store = lightcraft_catalog::MemStore::new();
let mut s = memory_library(&store);
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.xmp", "Edits"), ("/p/other.jpg", "Other")]);
for (id, path) in [(1, "/p/a.jpg"), (2, "/p/other.jpg")] {
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPEG", 16, 16, "");
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
}
select(&mut s, &[1], 1);
let copy = s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
s.undo.clear();
let before = fs.listing();
for name in ["middle", "final"] {
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], name, 1);
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
assert_file_catalog_and_journal(&s, &fs, &store);
}
let renamed = fs.listing();
s.merge_undo(2, "Rename Twice");
assert_eq!(s.undo.len(), 1);
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]);
// First exercise the ordinary merged step, before any corrective paths exist.
for redo in [false, true] {
file_history_step(&mut s, &fs, redo).unwrap();
assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() });
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, s.catalog.photo(PhotoId(copy)).unwrap().source);
assert_file_catalog_and_journal(&s, &fs, &store);
}
for redo in [false, true, false] {
select(&mut s, &[2], 2);
let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() };
let moves = s.file_moves(&entry.op);
assert_eq!(moves.len(), 1, "virtual copies must share the final physical move");
let (from, to) = &moves[0];
let sidecar = sidecar_path(from, SidecarNaming::Stem).to_string_lossy().into_owned();
*fs.fail.borrow_mut() = vec![sidecar, to.clone()];
let history = (s.undo.len(), s.redo.len());
assert!(file_history_step(&mut s, &fs, redo).is_err());
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.selection, Selection::single(PhotoId(2)));
assert_file_catalog_and_journal(&s, &fs, &store);
fs.fail.borrow_mut().clear();
file_history_step(&mut s, &fs, redo).unwrap();
assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() });
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, s.catalog.photo(PhotoId(copy)).unwrap().source);
assert_file_catalog_and_journal(&s, &fs, &store);
}
}
#[test]
fn partial_file_history_failures_retry_without_losing_either_inverse_or_journal() {
use crate::rename::tests::FakeFs;
for redo in [false, true] {
let store = lightcraft_catalog::MemStore::new();
let mut s = memory_library(&store);
let fs = FakeFs::new(
false,
&[
("/p/a.jpg", "A"),
("/p/a.xmp", "A edits"),
("/p/b.jpg", "B"),
("/p/b.xmp", "B edits"),
("/p/c.jpg", "C"),
("/p/c.xmp", "C edits"),
("/p/other.jpg", "Other"),
],
);
let ops = ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg", "/p/other.jpg"]
.iter()
.enumerate()
.map(|(i, path)| {
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
let photo = Photo::new(PhotoId(i as u64 + 1), Source::File { path: (*path).into() }, &name, "JPEG", 16, 16, "");
Op::AddPhoto { photo: Box::new(photo) }
})
.collect();
s.commit("Synthetic Files", Op::Batch { ops }).unwrap();
// A virtual copy shares B's physical file and must follow every partial correction too.
let mut copy = (**s.catalog.photo(PhotoId(2)).unwrap()).clone();
copy.id = PhotoId(5);
copy.copy_of = Some(PhotoId(2));
s.commit("Synthetic Copy", Op::AddPhoto { photo: Box::new(copy) }).unwrap();
s.persist().unwrap();
s.undo.clear();
select(&mut s, &[1, 2, 3], 1);
let before = fs.listing();
let plans = s.plan_rename_with(&fs, &[PhotoId(1), PhotoId(2), PhotoId(3)], "Trip-{seq}", 1);
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
let renamed = fs.listing();
if redo {
file_history_step(&mut s, &fs, false).unwrap();
}
select(&mut s, &[4], 4);
// Two bounded failures leave a different file moved on each attempt. A subsequent
// failure with no moves must keep those corrections; a final retry round-trips them.
for attempt in 0..2 {
let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() };
let moves = s.file_moves(&entry.op);
assert_eq!(moves.len(), 3 - attempt);
*fs.fail.borrow_mut() = vec![moves[1].0.clone(), moves[0].1.clone()];
let history = (s.undo.len(), s.redo.len());
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
assert!(error.contains("could not be moved back"), "{error}");
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.selection, Selection::single(PhotoId(4)));
assert_file_catalog_and_journal(&s, &fs, &store);
}
let entry = if redo { s.redo.last().unwrap() } else { s.undo.last().unwrap() };
let moves = s.file_moves(&entry.op);
assert_eq!(moves.len(), 1);
*fs.fail.borrow_mut() = vec![moves[0].0.clone()];
let unchanged = fs.listing();
let history = (s.undo.len(), s.redo.len());
assert!(file_history_step(&mut s, &fs, redo).is_err());
assert_eq!(fs.listing(), unchanged);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.selection, Selection::single(PhotoId(4)));
assert_file_catalog_and_journal(&s, &fs, &store);
fs.fail.borrow_mut().clear();
file_history_step(&mut s, &fs, redo).unwrap();
assert_eq!(fs.listing(), if redo { renamed.clone() } else { before.clone() });
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, !redo).unwrap();
assert_eq!(fs.listing(), if redo { before.clone() } else { renamed.clone() });
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, redo).unwrap();
assert_eq!(fs.listing(), if redo { renamed } else { before });
assert_file_catalog_and_journal(&s, &fs, &store);
}
}
#[test]
fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollback() {
use crate::rename::MoveFs;
use crate::sidecar::{SidecarNaming, sidecar_path};
for redo in [false, true] {
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
let (mut s, fs, store) = sidecar_history_fixture(redo);
let moves = s.file_moves(&sidecar_history_entry(&s, redo).op);
assert_eq!(moves.len(), 1);
let (from, to) = &moves[0];
let from = from.clone();
let to = to.clone();
let before = fs.listing();
let failing = sidecar_path(&from, naming).with_extension("XMP");
*fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned(), to.clone()];
let history = (s.undo.len(), s.redo.len());
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
assert!(error.contains("could not be moved back"), "{error}");
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: to.clone() });
assert!(s.file_moves(&sidecar_history_entry(&s, redo).op).is_empty(), "the photo already followed its destination");
let expected_pending = if naming == SidecarNaming::Stem { 4 } else { 2 };
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), expected_pending);
assert_file_catalog_and_journal(&s, &fs, &store);
// Repeating the same XMP failure must not turn the empty photo move list into success.
*fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned()];
let partial = fs.listing();
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
assert!(error.contains("injected failure"), "{error}");
assert_eq!(fs.listing(), partial);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.selection, Selection::single(PhotoId(2)));
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), expected_pending);
assert_file_catalog_and_journal(&s, &fs, &store);
// Make progress, then fail at the other full-name spelling. Completed sidecars
// must not be retried (their old names no longer exist).
let last = sidecar_history_entry(&s, redo).retry_sidecars.last().unwrap().clone();
*fs.fail.borrow_mut() = vec![last.from.to_string_lossy().into_owned()];
assert!(file_history_step(&mut s, &fs, redo).is_err());
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), 1);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.selection, Selection::single(PhotoId(2)));
assert_file_catalog_and_journal(&s, &fs, &store);
// A new destination collision stays an error and never replaces unrelated bytes.
fs.fail.borrow_mut().clear();
fs.files.borrow_mut().push((last.to.to_string_lossy().into_owned(), b"Unrelated edits".to_vec()));
let occupied = fs.listing();
assert!(file_history_step(&mut s, &fs, redo).is_err());
assert_eq!(fs.listing(), occupied);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(sidecar_history_entry(&s, redo).retry_sidecars.len(), 1);
assert_file_catalog_and_journal(&s, &fs, &store);
fs.remove_file(&last.to).unwrap();
file_history_step(&mut s, &fs, redo).unwrap();
let mut after = before
.iter()
.map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone()))
.collect::<Vec<_>>();
after.sort();
assert_eq!(fs.listing(), after);
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, !redo).unwrap();
assert_eq!(fs.listing(), before);
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, redo).unwrap();
assert_eq!(fs.listing(), after);
assert_file_catalog_and_journal(&s, &fs, &store);
}
}
}
#[test]
fn history_repairs_a_sidecar_whose_rollback_failed_before_retrying_the_photo() {
use crate::sidecar::{SidecarNaming, sidecar_path};
for redo in [false, true] {
let (mut s, fs, store) = sidecar_history_fixture(redo);
let moves = s.file_moves(&sidecar_history_entry(&s, redo).op);
let (from, to) = &moves[0];
let from = from.clone();
let to = to.clone();
let before = fs.listing();
// The stem sidecars move first; full-name failure rolls the photo back, but the
// first stem sidecar cannot return. The catalog has not changed this time.
let failed_full = sidecar_path(&from, SidecarNaming::Full).with_extension("XMP");
let stranded = sidecar_path(&to, SidecarNaming::Stem).with_extension("XMP");
*fs.fail.borrow_mut() = vec![failed_full.to_string_lossy().into_owned(), stranded.to_string_lossy().into_owned()];
let history = (s.undo.len(), s.redo.len());
let error = file_history_step(&mut s, &fs, redo).unwrap_err().to_string();
assert!(error.contains("sidecar") && error.contains("could not be moved back"), "{error}");
let entry = sidecar_history_entry(&s, redo);
assert_eq!(entry.retry_sidecars.len(), 1);
assert!(entry.retry_inverse.is_empty());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: from });
assert_file_catalog_and_journal(&s, &fs, &store);
let partial = fs.listing();
*fs.fail.borrow_mut() = vec![stranded.to_string_lossy().into_owned()];
assert!(file_history_step(&mut s, &fs, redo).is_err());
assert_eq!(fs.listing(), partial);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.selection, Selection::single(PhotoId(2)));
fs.fail.borrow_mut().clear();
file_history_step(&mut s, &fs, redo).unwrap();
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, !redo).unwrap();
assert_eq!(fs.listing(), before);
assert_file_catalog_and_journal(&s, &fs, &store);
}
}
#[test]
fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() {
use crate::rename::tests::FakeFs;
let store = lightcraft_catalog::MemStore::new();
let mut s = memory_library(&store);
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.xmp", "Stem edits"), ("/p/a.jpg.xmp", "Full edits")]);
let photo = Photo::new(PhotoId(1), Source::File { path: "/p/a.jpg".into() }, "a.jpg", "JPEG", 16, 16, "");
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
s.persist().unwrap();
s.undo.clear();
select(&mut s, &[1], 1);
let before = fs.listing();
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()];
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() });
assert_eq!(s.undo.len(), 1);
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2);
assert_file_catalog_and_journal(&s, &fs, &store);
let retry = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
assert_eq!(retry[0].from_path, retry[0].to_path, "the physical photo already has the requested name");
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into()];
let partial = fs.listing();
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &retry)?}))).is_err());
assert_eq!(fs.listing(), partial);
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2, "a failed no-op retry must keep its repair queue");
fs.fail.borrow_mut().clear();
assert_eq!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &retry)?}))).unwrap()["renamed"], 0);
assert!(s.undo.last().unwrap().retry_sidecars.is_empty());
assert_eq!(s.undo.len(), 1, "a repaired no-op retry adds no empty rename step");
let after = fs.listing();
assert_eq!(
after,
vec![("/p/Trip.jpg".into(), "Photo".into()), ("/p/Trip.jpg.xmp".into(), "Full edits".into()), ("/p/Trip.xmp".into(), "Stem edits".into())]
);
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, false).unwrap();
assert_eq!(fs.listing(), before);
file_history_step(&mut s, &fs, true).unwrap();
assert_eq!(fs.listing(), after);
assert_file_catalog_and_journal(&s, &fs, &store);
}
#[test]
fn initial_sidecar_rollback_failure_keeps_a_repair_only_history_step() {
use crate::rename::tests::FakeFs;
let store = lightcraft_catalog::MemStore::new();
let mut s = memory_library(&store);
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo"), ("/p/a.XMP", "Stem edits"), ("/p/a.jpg.XMP", "Full edits")]);
let photo = Photo::new(PhotoId(1), Source::File { path: "/p/a.jpg".into() }, "a.jpg", "JPEG", 16, 16, "");
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
s.persist().unwrap();
s.undo.clear();
select(&mut s, &[1], 1);
let before = fs.listing();
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
*fs.fail.borrow_mut() = vec!["/p/a.jpg.XMP".into(), "/p/Trip.XMP".into()];
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
assert_eq!(s.undo.len(), 1);
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 1);
assert_file_catalog_and_journal(&s, &fs, &store);
let partial = fs.listing();
*fs.fail.borrow_mut() = vec!["/p/Trip.XMP".into()];
assert!(file_history_step(&mut s, &fs, false).is_err());
assert_eq!(fs.listing(), partial);
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 1);
fs.fail.borrow_mut().clear();
file_history_step(&mut s, &fs, false).unwrap();
assert_eq!(fs.listing(), before);
assert_file_catalog_and_journal(&s, &fs, &store);
file_history_step(&mut s, &fs, true).unwrap();
assert_eq!(fs.listing(), before, "redo of the repair-only step must not rename healthy files");
assert_file_catalog_and_journal(&s, &fs, &store);
}
#[test]
fn discarded_file_history_keeps_sidecar_repairs_without_stale_catalog_inverses() {
use crate::rename::MoveFs;
use crate::sidecar::{SidecarNaming, sidecar_path};
for redo in [false, true] {
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
let (mut s, fs, store) = sidecar_history_fixture(redo);
let moves = s.file_moves(&sidecar_history_entry(&s, redo).op);
let (from, to) = moves[0].clone();
let failing = sidecar_path(&from, naming).with_extension("XMP");
*fs.fail.borrow_mut() = vec![failing.to_string_lossy().into_owned(), to.clone()];
assert!(file_history_step(&mut s, &fs, redo).is_err());
let pending = sidecar_history_entry(&s, redo).retry_sidecars.clone();
assert!(!sidecar_history_entry(&s, redo).retry_inverse.is_empty());
// A new edit discards Redo. Enough edits also evict an unresolved Undo entry.
for i in 0..if redo { 1 } else { 1000 } {
s.execute("photo.rate", &json!({"ids": [2], "rating": i % 5 + 1})).unwrap();
}
assert!(s.redo.is_empty());
assert_eq!(s.pending_sidecar_repairs, pending);
assert!(s.undo.iter().all(|e| e.retry_inverse.is_empty() && e.retry_sidecars.is_empty()));
let partial = fs.listing();
// Metadata history remains usable without write access to the separated XMP.
file_history_step(&mut s, &fs, false).unwrap();
file_history_step(&mut s, &fs, true).unwrap();
assert_eq!(s.pending_sidecar_repairs, pending);
assert_eq!(fs.listing(), partial);
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: to.clone() });
assert_file_catalog_and_journal(&s, &fs, &store);
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Next", 1);
let history = (s.undo.len(), s.redo.len());
assert!(s.apply_rename_with(&fs, &plans).is_err());
assert_eq!(fs.listing(), partial);
assert_eq!((s.undo.len(), s.redo.len()), history);
assert_eq!(s.pending_sidecar_repairs, pending);
// Retain only unfinished work after partial progress; never overwrite a collision.
let last = pending.last().unwrap().clone();
*fs.fail.borrow_mut() = vec![last.from.to_string_lossy().into_owned()];
assert!(s.apply_rename_with(&fs, &plans).is_err());
assert_eq!(s.pending_sidecar_repairs.len(), 1);
crate::rename::tests::assert_repair_packets(
&s.pending_sidecar_repairs[0],
&last,
&[
(sidecar_path(&to, SidecarNaming::Stem).with_extension("XMP"), b"Upper stem edits"),
(sidecar_path(&to, SidecarNaming::Stem), b"Lower stem edits"),
(sidecar_path(&to, SidecarNaming::Full).with_extension("XMP"), b"Upper full-name edits"),
(last.from.clone(), b"Lower full-name edits"),
],
);
// Successful repairs advance companion paths while retaining their content versions.
let last = s.pending_sidecar_repairs[0].clone();
fs.fail.borrow_mut().clear();
fs.files.borrow_mut().push((last.to.to_string_lossy().into_owned(), b"Other edits".to_vec()));
let occupied = fs.listing();
assert!(s.apply_rename_with(&fs, &plans).is_err());
assert_eq!(fs.listing(), occupied);
assert_eq!(s.pending_sidecar_repairs, vec![last.clone()]);
fs.remove_file(&last.to).unwrap();
// A no-op Rename still repairs the detached sidecars, without a new undo step.
let noop = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1);
assert_eq!(s.apply_rename_with(&fs, &noop).unwrap(), 0);
assert!(s.pending_sidecar_repairs.is_empty());
assert_eq!((s.undo.len(), s.redo.len()), history);
let repaired = fs.listing();
assert_file_catalog_and_journal(&s, &fs, &store);
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
let renamed = fs.listing();
file_history_step(&mut s, &fs, false).unwrap();
assert_eq!(fs.listing(), repaired, "new history restores the current name, not the discarded inverse");
file_history_step(&mut s, &fs, true).unwrap();
assert_eq!(fs.listing(), renamed);
assert_file_catalog_and_journal(&s, &fs, &store);
}
}
}
#[test]
fn merged_rename_failure_keeps_rolled_back_photo_identity() {
use crate::rename::tests::FakeFs;
let store = lightcraft_catalog::MemStore::new();
let mut s = memory_library(&store);
let fs = FakeFs::new(false, &[("/p/a.jpg", "Photo 1"), ("/p/a.xmp", "Photo 1 edits"), ("/p/b.jpg", "Photo 2")]);
for (id, path) in [(1, "/p/a.jpg"), (2, "/p/b.jpg")] {
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy();
let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPEG", 16, 16, "");
s.commit("Synthetic File", Op::AddPhoto { photo: Box::new(photo) }).unwrap();
}
s.persist().unwrap();
s.undo.clear();
for (id, name) in [(1, "c"), (2, "a"), (2, "d")] {
select(&mut s, &[id], id);
let plans = s.plan_rename_with(&fs, &[PhotoId(id)], name, 1);
s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).unwrap();
}
assert_eq!(
fs.listing(),
vec![
("/p/c.jpg".to_string(), "Photo 1".to_string()),
("/p/c.xmp".to_string(), "Photo 1 edits".to_string()),
("/p/d.jpg".to_string(), "Photo 2".to_string()),
]
);
s.merge_undo(3, "Rename Three Times");
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]);
// The second photo move sticks; the preceding d -> b move must roll back to d.
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()];
let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string();
assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}");
assert!(error.contains("/p/c.jpg could not be moved back"), "{error}");
let files = fs.listing();
assert_eq!(
files,
vec![
("/p/a.jpg".to_string(), "Photo 1".to_string()),
("/p/c.xmp".to_string(), "Photo 1 edits".to_string()),
("/p/d.jpg".to_string(), "Photo 2".to_string()),
]
);
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
let photo2 = s.catalog.photo(PhotoId(2)).unwrap();
assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg");
let Source::File { path } = &photo2.source else { panic!("file fixture") };
assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
}
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more