Compare commits

...
958 Commits
Author SHA1 Message Date
skylar taylor-barrick f34c7a8a89 Merge pull request #967 from dylanmtaylor/refactor/dry-github-actions
ci: refactor workflows to use storytold/craft-common-actions
2026-10-15 21:34:12 -06:00
skylartaylor 3885b61e6b ci: retain LightCraft installer and package checks 2026-10-16 05:25:18 +02:00
Dylan M. Taylor 0802892132 ci: refactor workflows to use storytold/craft-common-actions 2026-10-15 21:19:37 -04:00
Brandon Thomas 0da998f24d Merge pull request #1867 from storytold/claude/integrate-batch-40
Integration batch 40: 3 rebased community PRs (GPU face detection, soft-proof zoom windows, faster folder sync) and restored tracker rows
2026-10-15 04:00:13 -04:00
Brandon ThomasandClaude Opus 5.5 e8856c828c Batch 40: raw-parity action 3 (verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies) is done (#1045, #1081)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:45:11 -04:00
Brandon ThomasandClaude Opus 5.5 b17483a3ce Batch 40: tracker rows that earlier batches' doc merges dropped are back
Batches 36's doc conflicts kept main's side of rows both sides had touched; for some, main had not changed the row,
so the PR's update was lost. Restored: LR-VIEW-FILMSTRIP (#1482), KEYC-COMPARE (#1491 + #1555), LR-IMP-LOCAL
(#1547 + #1820), LR-LIB-STACK (#1556), gaps 28 (#1555), 2 (#1029) and 23 (#1038), ROADMAP UI/UX (#1038). The
raw-parity by-maker and by-use tables are re-measured with coverage.py (545 verified, ~94% / ~85%).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:45:00 -04:00
Brandon ThomasandClaude Opus 5.5 e6dceb9f89 Batch 40: #1027's LR-VIEW-ZOOM row (✅, mask and proof zoom windows) kept; parity summary refreshed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:43:21 -04:00
Brandon Thomas 5e9f7f1a65 Merge PR #1027 (rebased)
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
2026-10-15 03:40:37 -04:00
Brandon Thomas 9ddb8d1824 Merge PR #1663 (rebased)
# 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-15 03:40:17 -04:00
Brandon Thomas 8291f08ab7 Merge PR #1602 (rebased) 2026-10-15 03:40:15 -04:00
Brandon ThomasandClaude Opus 5.5 1d7699056e Merge PR #1027
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:36:18 -04:00
Brandon ThomasandClaude Opus 5.5 2179473536 Merge PR #1663
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:24:51 -04:00
Brandon Thomas 132b0e3ac8 Merge pull request #1865 from storytold/claude/integrate-batch-39
Integration batch 39: 4 rebased community PRs (Georgian, slider scrub and reset hints, straight brush lines) and the Windows ARM64 font pin
2026-10-15 03:08:47 -04:00
Brandon ThomasandClaude Opus 5.5 00dced3dae Batch 39: #1829's straight brush lines carry pen pressure as the author merged them with #1033 (the whole masks.rs from the PR head)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:07:35 -04:00
Brandon ThomasandClaude Opus 5.5 4212907656 Merge PR #1602
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:02:56 -04:00
Brandon Thomas fd80e4eb2f Merge PR #1829 (updated head) 2026-10-15 03:01:12 -04:00
Brandon ThomasandClaude Opus 5.5 d6f63f244f Batch 39: parity summary refreshed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:00:59 -04:00
Brandon ThomasandClaude Opus 5.5 fd9d045c75 CI: the packaging scripts check out the craft-fonts revision release.yml pins (3d9ab3a, with Noto Sans Thai)
Windows ARM64 on main failed since batch 36: #1479's scripts pinned 8dcdacd, which predates the Thai face #1311
made required.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:57:33 -04:00
Brandon ThomasandClaude Opus 5.5 a22f9a6d46 Batch 39: hang guards of the slideshow PDF export and slow-import tests allow a loaded machine (both pass in seconds alone)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:51:35 -04:00
Brandon ThomasandClaude Opus 5.5 51d8018e27 Merge PR #1823 (rebased)
Georgian next to Italian in the language table, menus, catalogs list and docs; ka-formats.json lists the format
strings other PRs added, in English until translated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:39:01 -04:00
Brandon ThomasandClaude Opus 5.5 f0b5f0339a Batch 39: #1474's hint test expects the value hint #1604 gave the scrubbable value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:38:20 -04:00
Brandon Thomas 88ca6ed572 Merge PR #1474 (rebased)
# Conflicts:
#	crates/ui-egui/src/widgets.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 02:32:41 -04:00
Brandon Thomas 780872b158 Merge PR #1604 (rebased)
# Conflicts:
#	docs/parity-checklist.md
2026-10-15 02:32:16 -04:00
Brandon Thomas 809259a37d Merge PR #1829 (rebased)
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 02:32:15 -04:00
Brandon ThomasandClaude Opus 5.5 bc39923608 Merge PR #1604
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:28:24 -04:00
Brandon ThomasandClaude Opus 5.5 d9ed118c1d Merge PR #1474
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:13:45 -04:00
Michael Anderson fc6719d1db Merge remote-tracking branch 'upstream/main' into feat/brush-shift-click
# Conflicts:
#	docs/whats-new.md
2026-10-15 01:07:50 -05:00
Brandon Thomas a82a001d20 Merge pull request #1857 from storytold/claude/integrate-batch-38
Integration batch 38: 6 rebased community PRs (Nikon camera profiles, lens-corrected colour, HDR frame order, focus points)
2026-10-15 02:00:39 -04:00
Brandon Thomas 2f1acef58f Merge PR #1595 (updated head) 2026-10-15 02:00:04 -04:00
Brandon ThomasandClaude Opus 5.5 a39e85da72 Merge PR #1829
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:00:02 -04:00
Brandon ThomasandClaude Opus 5.5 a94fc6bc84 Batch 38: What's New lists the Nikon profile and lens-corrected colour notes once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:59:50 -04:00
Brandon Thomas 3f69729d69 Merge PR #1595 (updated head) 2026-10-15 01:59:36 -04:00
Brandon Thomas 0270d30eda Merge PR #1592 (updated head) 2026-10-15 01:59:36 -04:00
Brandon Thomas 956420ea5f Merge PR #1586 (updated head) 2026-10-15 01:59:36 -04:00
Brandon ThomasandClaude Opus 5.5 e44e7157fc Batch 38: rustfmt and parity summary after the merges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:58:56 -04:00
Michael Anderson c877e8e9ee Merge remote-tracking branch 'origin/feat/nikon-z6ii-camera-profile' into feat/nikon-z8-d5300-profiles
# Conflicts:
#	README.md
#	README.zh-hans.md
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/tests_xmp.rs
#	docs/camera-preview-colour.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 00:58:42 -05:00
Michael Anderson 296e2ce73c Merge remote-tracking branch 'origin/fix/colour-fit-lens-geometry' into feat/nikon-z6ii-camera-profile
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/tests_xmp.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 00:55:18 -05:00
Michael Anderson a77eafb32a Merge remote-tracking branch 'upstream/main' into feat/brush-shift-click
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:54:23 -05:00
Brandon ThomasandClaude Opus 5.5 73ee54fe62 Batch 38: format catalogs list the strings this batch adds, in English until translated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:48:46 -04:00
Brandon Thomas 11351ef141 Merge PR #1834 (rebased)
# Conflicts:
#	crates/ui-egui/locales/cs-formats.json
#	crates/ui-egui/locales/pl-formats.json
#	crates/ui-egui/src/lib.rs
2026-10-15 01:48:46 -04:00
Brandon ThomasandClaude Opus 5.5 202fcbb525 Merge PR #1823
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:48:43 -04:00
Michael Anderson 9ad2c1766b Merge remote-tracking branch 'upstream/main' into fix/colour-fit-lens-geometry
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/files.rs
#	docs/whats-new.md
2026-10-15 00:48:09 -05:00
Brandon Thomas d5b2cd1474 Merge PR #1595 (rebased)
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 01:48:05 -04:00
Brandon Thomas 97959610be Merge PR #1592 (rebased)
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 01:47:46 -04:00
Brandon Thomas 99eba2e069 Merge PR #1586 (rebased)
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/files.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 01:45:53 -04:00
Brandon Thomas 0fb809c671 Merge PR #1614 (rebased)
# Conflicts:
#	crates/merge/src/hdr.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-15 01:44:24 -04:00
Brandon Thomas e4519aa710 Merge PR #1487 (rebased)
# Conflicts:
#	docs/parity-checklist.md
2026-10-15 01:43:02 -04:00
Brandon ThomasandClaude Opus 5.5 26e43cd691 Merge PR #1595
Stacked on rb/1592: LOOK_VERSION 8 -> 9; the PR's corpus-profile helper is the base's corpus_uses_the_bundled_profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:42:11 -04:00
Brandon ThomasandClaude Opus 5.5 480e96c030 Merge PR #1592
Stacked on rb/1586: LOOK_VERSION 7 -> 8; README lists the base's ILCA-99M2 profile too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:42:11 -04:00
Brandon ThomasandClaude Opus 5.5 2756414f46 Merge PR #1586
Resolved against rb-base-36: LOOK_VERSION 6 -> 7; starting_colour_tags passes no lens (merge frames arrive without it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:42:11 -04:00
Brandon ThomasandClaude Opus 5.5 3c0300fcbb Merge PR #1614
Resolved against rb-base-36: docs combine the 1 px clip neighbourhood and frame order with the base's moving-highlight rule and 2048 px deghost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:42:11 -04:00
Brandon ThomasandClaude Opus 5.5 b5733c7649 Merge PR #1487
Resolved against rb-base-36: Scanned keeps the base's rewrite flag next to magic and tag; Exif keeps the Panasonic lens fallback; locale catalogs merged (one duplicate key dropped).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:42:11 -04:00
Brandon Thomas 3d003e8c64 Merge pull request #1852 from storytold/claude/integrate-batch-37
Integration batch 37: 20 rebased community PRs (print module, decode speed-ups, camera coverage, Italian)
2026-10-15 01:37:33 -04:00
Brandon ThomasandClaude Opus 5.5 a981538d05 Batch 37: the #1506 What's New entry once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:36:59 -04:00
Brandon Thomas c870c32ce5 Merge PR #1469 (updated head) 2026-10-15 01:36:27 -04:00
Brandon Thomas 81849a499e Merge PR #1773 (updated head) 2026-10-15 01:36:27 -04:00
Brandon ThomasandClaude Opus 5.5 2a4b53f86c Batch 37: parity summary refreshed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:34:43 -04:00
Brandon ThomasandClaude Opus 5.5 b00d7c0267 Batch 37: the contact-sheet export test's deadline is a hang guard (it queues behind other tests' demo scenes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:33:01 -04:00
cookingwithsimonandClaude Opus 5.5 77a37683bc LR-IMP-PROFILES: merge main into presets-apply-imported-profile (#1469)
Resolves conflicts with integration batch 36 (local adjustment presets):
the preset.import help and docs now describe both the mask-effect import
and the profile a preset names; both sides' tests are kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01GitCXGGRfeWqEhhnzH6s6D
2026-10-15 05:31:03 +00:00
Brandon ThomasandClaude Opus 5.5 dec7fd4eb2 Merge PR #1834
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:26:47 -04:00
Brandon Thomas 51a784cd95 Merge PR #1475 (rebased)
# Conflicts:
#	crates/raw/examples/rawinfo.rs
#	crates/raw/src/lib.rs
#	crates/raw/src/vendor/arw.rs
#	crates/raw/src/vendor/erf.rs
#	crates/raw/src/vendor/mef.rs
#	crates/raw/src/vendor/nef.rs
#	crates/raw/tests/lossy_dng.rs
#	crates/raw/tests/robust.rs
2026-10-15 01:13:36 -04:00
Brandon ThomasandClaude Opus 5.5 52a3b0ed84 Batch 37: #1471's test mosaic sets #1176's lens_metadata
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:12:03 -04:00
Brandon Thomas cf0002b484 Merge PR #1471 (rebased)
# Conflicts:
#	docs/parity-checklist.md
2026-10-15 01:10:17 -04:00
Brandon ThomasandClaude Opus 5.5 e5cc449bd1 Batch 37: the contact-sheet export and batch-merge tests wait longer on a busy machine (they pass alone, timed out under the full test run)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:10:07 -04:00
Brandon ThomasandClaude Opus 5.5 edfd4bc80a Batch 37: conflict markers left in three tracker headers by the #1327 merge resolved
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:07:22 -04:00
Brandon Thomas 66a0cd826c Merge PR #1475
# Conflicts:
#	crates/merge/src/output.rs
#	crates/raw/src/vendor/nef.rs
2026-10-15 01:05:31 -04:00
Brandon Thomas 7f7401713a Merge PR #1471
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/merge.rs
#	crates/merge/src/frame.rs
#	crates/merge/src/hdr.rs
#	docs/parity-checklist.md
2026-10-15 00:56:52 -04:00
Brandon Thomas fbb9583d41 Merge PR #1810 (rebased)
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:56:51 -04:00
Brandon ThomasandClaude Opus 5.5 3c489dc408 Batch 37: the print commands (#1215) take the Result that Session::targets returns since #1773
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:56:44 -04:00
Brandon ThomasandClaude Opus 5.5 4468917a1b Merge PR #1810
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:54:13 -04:00
Brandon ThomasandClaude Opus 5.5 60b938ab52 Batch 37: format catalogs list the strings this batch adds, in English until translated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:53:53 -04:00
Brandon Thomas 3b6d15355b Merge PR #1298 (rebased)
# Conflicts:
#	crates/ui-egui/src/panels/dialogs.rs
2026-10-15 00:53:53 -04:00
Brandon Thomas 9ca113defa Merge PR #1135 (rebased)
# Conflicts:
#	xtask/src/main.rs
2026-10-15 00:53:45 -04:00
Brandon Thomas 76881c977e Merge PR #1327 (rebased)
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
2026-10-15 00:53:22 -04:00
Brandon Thomas 02000d98a4 Merge PR #1198 (rebased)
# Conflicts:
#	crates/ui-egui/src/panels/keyword_list.rs
2026-10-15 00:52:53 -04:00
Brandon Thomas 8591cdfa6b Merge PR #1469 (rebased)
# Conflicts:
#	crates/engine/src/lib.rs
#	docs/parity-checklist.md
2026-10-15 00:52:33 -04:00
Brandon Thomas 0af2d36442 Merge PR #1320 (rebased) 2026-10-15 00:52:32 -04:00
Brandon Thomas cab59313fd Merge PR #1316 (rebased) 2026-10-15 00:52:32 -04:00
Brandon Thomas b3012745d5 Merge PR #1215 (rebased)
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-15 00:52:31 -04:00
Brandon Thomas f7204aa8dd Merge PR #1176 (rebased) 2026-10-15 00:52:31 -04:00
Brandon Thomas 8b73a2ec2f Merge PR #1138 (rebased)
# Conflicts:
#	docs/gaps.md
2026-10-15 00:52:30 -04:00
Brandon Thomas 05aa012f37 Merge PR #1802 (rebased)
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:52:30 -04:00
Brandon Thomas a690478e52 Merge PR #1773 (rebased)
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:52:29 -04:00
Brandon Thomas 2f58918162 Merge PR #1762 (rebased)
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-15 00:52:29 -04:00
Brandon Thomas 7c5f36bd8c Merge PR #1761 (rebased)
# Conflicts:
#	docs/whats-new.md
2026-10-15 00:52:29 -04:00
Brandon Thomas 30168d759d Merge PR #1755 (rebased) 2026-10-15 00:52:28 -04:00
Brandon Thomas bc7abfae14 Merge PR #1708 (rebased) 2026-10-15 00:52:28 -04:00
Brandon Thomas 7b49657a26 Merge PR #1705 (rebased) 2026-10-15 00:52:27 -04:00
Brandon Thomas 12eab11cf5 Merge PR #1469
# Conflicts:
#	crates/engine/src/cmd/preset_files.rs
#	crates/engine/src/tests_import.rs
#	docs/parity-checklist.md
#	docs/xmp-interop.md
2026-10-15 00:50:08 -04:00
Michael Anderson dc562c654a Merge remote-tracking branch 'upstream/main' into feat/brush-shift-click
# Conflicts:
#	crates/engine/src/cmd/masks.rs
#	crates/ui-egui/src/panels/detail.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 23:45:14 -05:00
Brandon Thomas f27ca795c7 Merge PR #1327
# Conflicts:
#	ROADMAP.md
#	crates/raw/tests/corpus.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/raw/coverage.tsv
#	docs/raw/evidence.tsv
#	docs/target-app-parity.md
2026-10-15 00:44:42 -04:00
Brandon ThomasandClaude Opus 5.5 81a9569301 Batch 36: rustfmt after merging #1802
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:42:56 -04:00
Brandon ThomasandClaude Opus 5.5 1e49095146 Merge PR #1802
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:42:40 -04:00
Michael Anderson e48d5d428d Merge remote-tracking branch 'upstream/main' into fix/1506-explicit-targets
# Conflicts:
#	crates/engine/src/cmd/library.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/panels/grid.rs
#	crates/ui-egui/src/panels/keywording.rs
#	crates/ui-egui/src/panels/right.rs
#	docs/whats-new.md
2026-10-14 23:38:19 -05:00
Brandon Thomas 372f39f8f7 Merge PR #1320
# Conflicts:
#	crates/engine/src/files.rs
#	crates/raw/src/demosaic/ahd.rs
2026-10-15 00:38:16 -04:00
Brandon Thomas af8843bf01 Merge PR #1316
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/media.rs
2026-10-15 00:33:33 -04:00
Brandon ThomasandClaude Opus 5.5 2475cf5e32 Merge PR #1773
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:31:26 -04:00
Brandon Thomas ab96593df5 Merge PR #1298
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/src/panels/faces.rs
#	crates/ui-egui/src/shortcuts.rs
2026-10-15 00:29:27 -04:00
Michael Anderson bcffde1a06 Merge remote-tracking branch 'upstream/main' into feat/nikon-z8-d5300-profiles
# Conflicts:
#	README.md
#	README.zh-hans.md
#	crates/engine/src/camera_preview.rs
#	docs/camera-preview-colour.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 23:28:25 -05:00
Michael Anderson 68ef6ff3b3 Merge remote-tracking branch 'upstream/main' into feat/nikon-z6ii-camera-profile
# Conflicts:
#	README.md
#	README.zh-hans.md
#	crates/engine/src/camera_preview.rs
#	docs/camera-preview-colour.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 23:26:01 -05:00
Michael Anderson f9076514e7 Merge remote-tracking branch 'upstream/main' into fix/colour-fit-lens-geometry
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 23:22:42 -05:00
Brandon Thomas b29a1a8cc2 Merge PR #1215
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/pdf.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/src/state.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-15 00:22:08 -04:00
Brandon Thomas 7af7b31af6 Merge pull request #1843 from storytold/claude/integrate-batch-36
Integration batch 36: 110 reviewed community PRs (slideshow, camera verification, translations, never-crash, UI)
2026-10-15 00:16:05 -04:00
Brandon Thomas 26a3ae09a1 Merge PR #1009 (updated head)
# Conflicts:
#	docs/parity-checklist.md
2026-10-15 00:15:42 -04:00
Brandon Thomas 77dc170ede Merge PR #1053 (updated head)
# Conflicts:
#	crates/engine/examples/raw_frame.rs
#	crates/raw/src/vendor/nef.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
#	xtask/src/main.rs
2026-10-15 00:10:37 -04:00
Brandon ThomasandClaude Opus 5.5 b333199eb1 Merge PR #1762
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:09:51 -04:00
Brandon Thomas d08d8896b8 Merge PR #1832 (updated head)
# Conflicts:
#	docs/parity-checklist.md
2026-10-15 00:07:48 -04:00
Brandon Thomas e3a13438e9 Merge PR #1062 (updated head)
# Conflicts:
#	docs/parity-checklist.md
2026-10-15 00:07:34 -04:00
Brandon Thomas 431194d5fd Merge origin/main into batch 36 2026-10-15 00:07:19 -04:00
Brandon Thomas 57503c9a4e Merge PR #1198
# Conflicts:
#	ROADMAP.md
#	assets/ATTRIBUTION.md
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/keyword_list.rs
#	crates/ui-egui/src/panels/settings.rs
#	docs/localization-parity.md
#	docs/localization.md
2026-10-15 00:04:52 -04:00
Brandon ThomasandClaude Opus 5.5 1a120a86a2 Merge PR #1761
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:59:13 -04:00
Brandon Thomas f613aeba59 Merge PR #1176
# Conflicts:
#	crates/raw/src/vendor/arw.rs
#	docs/parity-checklist.md
#	docs/sony-lens-corrections.md
2026-10-14 23:54:06 -04:00
Brandon ThomasandClaude Opus 5.5 a0b55e66bb Merge PR #1755
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:20 -04:00
Brandon Thomas e6b2e6eefb Merge PR #1138
# Conflicts:
#	crates/catalog/src/lib.rs
#	crates/catalog/src/query.rs
#	crates/engine/src/cmd/library.rs
#	crates/ui-egui/src/tests_keywording.rs
#	docs/gaps.md
2026-10-14 23:46:16 -04:00
Brandon ThomasandClaude Opus 5.5 eb4cfe6c56 Batch 36: Canon RP C-RAW sample listed once (#1029 and #1453 both pinned it); the face-tile test waits up to a minute for its pictures on a busy machine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:39:57 -04:00
Brandon ThomasandClaude Opus 5.5 a0401d6949 Merge PR #1708
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:33:18 -04:00
Brandon ThomasandClaude Opus 5.5 851aedc64d Batch 36: tests and tracker after the merges
- Visualize HDR (#1555) has no default key: ⇧H cycles the Tool Overlay (#1473, issue #224)
- CR3 tests (#1461, #1462) accept adaptive QP 131–175, as #1694 decodes; cr3.md says the R6 Mark II files decode
- English fallbacks in the Czech and Polish catalogs use → for the menu triangle; Polish in the keyword-label test
- canvas handle tests show pins (Tool Overlay Always); a focused handle keeps its pins shown in Auto
- the model-install test and demo deadlock guards wait on deadlines long enough for a busy machine

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:25:29 -04:00
Brandon Thomas 9ca3cd0926 Merge PR #1135
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	README.md
#	crates/raw/Cargo.toml
#	crates/ui-egui/src/theme.rs
#	xtask/src/main.rs
2026-10-14 23:23:18 -04:00
skylar taylor-barrickandskylartaylor 38adc178a0 ci: add comfortable runtime limits to unbounded jobs (#1836)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:16:09 +02:00
Brandon ThomasandClaude Opus 5.5 b8c5913afe Merge PR #1705
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:11:45 -04:00
Brandon ThomasandClaude Opus 5.5 56022ffc2a Batch 36: D600/D610 measured areas listed once (#1053 and #1770 both added them, with the same crops)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:51:38 -04:00
robmdunnandClaude Opus 5.5 2b3b6e7871 LR-IMP-CAMERA-COVERAGE: measured image area for Nikon bodies without CropArea
Nikon bodies from before ~2016 record no CropArea, so the decoder kept
their whole sensor data, less trailing dark columns. Where that data
holds masked lines above black or optical black, they were developed as
a coloured or dark bar on that edge: the D7000 and D5100's 36 columns at
~600 (#976), the D800's 14 (#914, the top in portrait), the D600 / D610's
8 (#1236), the D3200's 46, and others.

MEASURED_BODIES (crates/raw/src/vendor/nef.rs) now gives 44 more model /
data size / depth groups the frame of their camera JPEG inside the data,
next to the D4, D7100 and D90 entries already there: D7000, D5100, D800,
D800E, Df, the D4's 12-bit mode, D3, D3S, D700, D300, D300S, D600, D610,
D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200
and Nikon 1 V1; and, on best-guess frames short of the evidence rule,
each with a comment naming its evidence, the D3100 (3 of 4 clean files
agree), D5000 (4 of 5, and the D90's sensor), D40 (2 of 2), D50 and D70
(one file each, agreeing) and D70s (next to them, between pixels).
Every best-guess frame keeps the masked lines out by several pixels.
An entry matches model, data size and depth exactly. Bodies whose data
holds only picture beyond the camera frame get no entry and render as
before.

`cargo xtask raw-frame FILE...` (crates/engine/examples/raw_frame.rs)
measures a body: it aligns each raw's embedded camera JPEG with the
sensor data (half-size luminance, gradient correlation, sub-pixel peak;
weak, ambiguous or between-pixel peaks are flagged), counts the edge
lines that hold no picture and reports what the decoder's crop keeps
beyond the camera frame. A masked line is flat and its two CFA phases
agree within the noise or within 0.4% of white, so filler with a small
fixed offset between them (D90, D5000) counts. It prints entries only
with evidence: at least 3 clean files agreeing exactly, of the model
(12- and 14-bit pooled) or, with at least one of its own, with sibling
models of the same readout (data and JPEG size) that agree on the same
frame. On the D7500, which records its CropArea, it finds that area
exactly; it declines the Z f, whose JPEG is distortion-corrected. It
works for any make whose raw embeds a full-size camera JPEG: on Sony,
Canon CR2 / CR3, Pentax and Samsung files it finds the decoder's own
crop (relative to the active area).

Every Nikon raw on raw.pixls.us without CropArea was surveyed this way
(its 138 CC0 samples, plus 124 other publicly posted sample files; only
the measurements are committed). Bodies whose data holds only picture
beyond the frame need no entry (D750, D5300, D5500, D3300, D2X, D2Hs,
D3X, D5200, Coolpix P6000, Nikon 1 AW1 / J3 / V2 / V3, the D800's crop
modes); raw-parity.md lists them, and no longer claims every NEF
coding but HE decodes. measured_bodies_are_consistent checks every entry lies
inside its data and is listed once; measured_older_dx_bodies_image_area
covers the D200, D3200, Nikon 1 V1 and best-guess entries.

Fixes #976
Fixes #914
Fixes #1236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:40:06 -04:00
Brandon ThomasandClaude Opus 5.5 211255770f Batch 36: #1510's rename follows the optional name #1665 gave person_from; #1780's test sets #974's preview_checked; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:35:34 -04:00
Brandon ThomasandClaude Opus 5.5 a740c1f390 Batch 36: format catalogs list the strings this batch adds, in English until translated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:33:28 -04:00
Brandon Thomas 32e870d589 Merge PR #1694
# Conflicts:
#	docs/cr3.md
#	docs/whats-new.md
2026-10-14 22:33:20 -04:00
Brandon Thomas a2784ff9b3 Merge PR #1738
# Conflicts:
#	docs/lightroom-catalog-import.md
2026-10-14 22:33:19 -04:00
Brandon Thomas 4660e7079e Merge PR #1820
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:33:18 -04:00
Brandon Thomas 133d575b8a Merge PR #1770
# Conflicts:
#	crates/raw/src/vendor/nef.rs
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-14 22:33:17 -04:00
Brandon Thomas 2fd769963e Merge PR #1549
# Conflicts:
#	crates/engine/src/tests_merge.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	docs/merge.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:33:16 -04:00
Brandon Thomas 084d95d630 Merge PR #1665
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 22:33:15 -04:00
Brandon Thomas ad23861454 Merge PR #1625
# Conflicts:
#	apps/lightcraft/src/main.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:33:14 -04:00
Brandon Thomas 5b3856ba2b Merge PR #1613
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 22:33:13 -04:00
Brandon Thomas f306bff0dc Merge PR #1556
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 22:33:12 -04:00
Brandon Thomas 4d4a537079 Merge PR #1555
# Conflicts:
#	docs/gaps.md
#	docs/library-shortcuts.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-14 22:33:11 -04:00
Brandon Thomas 87f0abbdb0 Merge PR #1547
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:33:11 -04:00
Brandon Thomas f8d1585c6f Merge PR #1510
# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-14 22:33:10 -04:00
Brandon Thomas 56cff6b11c Merge PR #1491
# Conflicts:
#	crates/ui-egui/src/menus.rs
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-14 22:33:08 -04:00
Brandon Thomas f933851a9e Merge PR #1482
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 22:33:07 -04:00
Brandon Thomas 9562532695 Merge PR #1662 2026-10-14 22:32:55 -04:00
Brandon Thomas 31f1f559dd Merge PR #1660 2026-10-14 22:32:54 -04:00
Brandon Thomas 43f9d51207 Merge PR #1656
# Conflicts:
#	crates/engine/src/library.rs
#	crates/engine/src/tests_library.rs
#	crates/ui-egui/src/menubar.rs
#	docs/parity-checklist.md
2026-10-14 22:32:53 -04:00
Brandon Thomas 65f51751a1 Merge PR #1637 2026-10-14 22:32:52 -04:00
Brandon Thomas 3698f76c40 Merge PR #1601
# Conflicts:
#	crates/ui-egui/src/headless.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/state.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:32:47 -04:00
Brandon Thomas bde5385a6f Merge PR #1590
# Conflicts:
#	crates/meta/src/exif.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:32:44 -04:00
Brandon Thomas c8c97883cd Merge PR #1548
# 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
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:32:39 -04:00
Brandon Thomas 5734d2e71c Merge PR #1540
# Conflicts:
#	docs/file-format-parity.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:32:33 -04:00
Brandon Thomas 1d006f7703 Merge PR #1538
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 22:32:30 -04:00
Brandon Thomas 61fa2237f6 Merge PR #1521 2026-10-14 22:32:29 -04:00
Brandon Thomas 7f8232e12c Merge PR #1495 2026-10-14 22:32:27 -04:00
Brandon Thomas 18db8588b9 Merge PR #1490
# Conflicts:
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-14 22:32:25 -04:00
Brandon Thomas de5ec99f61 Merge PR #1489
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:32:24 -04:00
Brandon Thomas 052aa9dccb Merge PR #1479 2026-10-14 22:32:20 -04:00
Brandon Thomas 5d1fade3d0 Merge PR #1473
# Conflicts:
#	crates/ui-egui/src/menubar.rs
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-14 22:32:17 -04:00
Brandon Thomas 75e0a6dd2a Merge PR #1835 2026-10-14 22:32:07 -04:00
Brandon Thomas 5c60c3656d Merge PR #1831 2026-10-14 22:32:04 -04:00
Brandon Thomas f03c4529c2 Merge PR #1818
# 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
#	docs/whats-new.md
2026-10-14 22:32:01 -04:00
Brandon Thomas 5e1a951008 Merge PR #1817 2026-10-14 22:31:59 -04:00
Brandon Thomas 33c321c6fb Merge PR #1816
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 22:31:58 -04:00
Brandon Thomas bbf8fc0f0b Merge PR #1813 2026-10-14 22:31:57 -04:00
Brandon Thomas 7ab2f2bfb4 Merge PR #1808 2026-10-14 22:31:56 -04:00
Brandon Thomas 213b09a2e9 Merge PR #1804 2026-10-14 22:31:55 -04:00
Brandon Thomas 327e67af9e Merge PR #1798
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:31:53 -04:00
Brandon Thomas f7fc3abb2f Merge PR #1797 2026-10-14 22:31:51 -04:00
Brandon Thomas 084c99bd8e Merge PR #1796 2026-10-14 22:31:51 -04:00
Brandon Thomas 556f3d040f Merge PR #1795 2026-10-14 22:31:48 -04:00
Brandon Thomas 6dab4bdc1b Merge PR #1793 2026-10-14 22:31:47 -04:00
Brandon Thomas c809c249d1 Merge PR #1792 2026-10-14 22:31:46 -04:00
Brandon Thomas fe2ba9191a Merge PR #1791 2026-10-14 22:31:46 -04:00
Brandon Thomas e4ad628398 Merge PR #1784 2026-10-14 22:31:45 -04:00
Brandon Thomas 19f094c952 Merge PR #1782
# Conflicts:
#	docs/whats-new.md
2026-10-14 22:31:44 -04:00
Brandon Thomas b476affb6b Merge PR #1780
# Conflicts:
#	docs/whats-new.md
2026-10-14 22:31:43 -04:00
Brandon Thomas fdd05f4141 Merge PR #1779
# Conflicts:
#	docs/whats-new.md
2026-10-14 22:31:42 -04:00
Brandon Thomas 260d03ced6 Merge PR #1777
# Conflicts:
#	docs/whats-new.md
2026-10-14 22:31:41 -04:00
Brandon Thomas 2c4390e2d0 Merge PR #1776
# Conflicts:
#	docs/whats-new.md
2026-10-14 22:31:40 -04:00
Brandon ThomasandClaude Opus 5.5 6cc0072843 Batch 36: the Czech and Polish format catalogs list the strings this batch adds, in English until translated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:31:00 -04:00
Brandon Thomas f815f88958 Merge PR #1772
# Conflicts:
#	crates/engine/src/tests_xmp.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:29:50 -04:00
Brandon Thomas 622f9e6492 Merge PR #1769
# Conflicts:
#	contributors/people.toml
2026-10-14 22:29:48 -04:00
Brandon Thomas 2011d19792 Merge PR #1767 2026-10-14 22:29:47 -04:00
Brandon Thomas b985090c90 Merge PR #1758 2026-10-14 22:29:45 -04:00
Brandon Thomas 2a5fae54b0 Merge PR #1753
# Conflicts:
#	crates/ui-egui/src/panels/edit.rs
2026-10-14 22:29:43 -04:00
Brandon Thomas e2f6555220 Merge PR #1750 2026-10-14 22:29:42 -04:00
Brandon Thomas da677cd440 Merge PR #1832
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 22:29:41 -04:00
Brandon Thomas b350127d81 Merge PR #1747 2026-10-14 22:29:40 -04:00
Brandon Thomas 81a64ed13d Merge PR #1744 2026-10-14 22:29:39 -04:00
Brandon Thomas 5242f02838 Merge PR #1741 2026-10-14 22:29:38 -04:00
Brandon Thomas 9d45f6b168 Merge PR #1733 2026-10-14 22:29:37 -04:00
Brandon Thomas 141aec6ddf Merge PR #1731 2026-10-14 22:29:36 -04:00
Brandon Thomas 32789beade Merge PR #1722 2026-10-14 22:29:35 -04:00
Brandon Thomas 5473bf514e Merge PR #1719 2026-10-14 22:29:34 -04:00
Brandon Thomas a8f0f5d60b Merge PR #1715
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
2026-10-14 22:29:33 -04:00
Brandon Thomas 9a64ecefc0 Merge PR #1710 2026-10-14 22:29:32 -04:00
Brandon Thomas fcbbfac464 Merge PR #1703 2026-10-14 22:29:29 -04:00
Brandon Thomas 1aea595527 Merge PR #1696
# Conflicts:
#	contributors/people.toml
2026-10-14 22:29:28 -04:00
Brandon Thomas 6f2ccf7e33 Merge PR #1693 2026-10-14 22:29:26 -04:00
Brandon Thomas 9107ea4bdf Merge PR #1688 2026-10-14 22:29:25 -04:00
Brandon Thomas 3be25e7559 Merge PR #1686 2026-10-14 22:29:25 -04:00
Brandon Thomas d9508ff909 Merge PR #1467 2026-10-14 22:29:15 -04:00
Brandon Thomas aa384d62f7 Merge PR #1466
# Conflicts:
#	docs/camera-preview-colour.md
2026-10-14 22:29:14 -04:00
Brandon Thomas 0bdb55cb4b Merge PR #1465 2026-10-14 22:29:13 -04:00
Brandon Thomas 60696fe7c1 Merge PR #1462 2026-10-14 22:29:12 -04:00
Brandon Thomas 0f842c8f87 Merge PR #1461
# Conflicts:
#	crates/raw/tests/robust.rs
2026-10-14 22:29:11 -04:00
Brandon Thomas de137ef5fc Merge PR #1311 2026-10-14 22:29:07 -04:00
Brandon Thomas 9854ca24cb Merge PR #1308
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-14 22:29:06 -04:00
Brandon Thomas 8a406d0dfc Merge PR #1211 2026-10-14 22:29:02 -04:00
Brandon Thomas 1449be9281 Merge PR #1190
# Conflicts:
#	contributors/people.toml
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/menus.rs
2026-10-14 22:29:00 -04:00
Brandon Thomas b70dcba75b Merge PR #1180 2026-10-14 22:28:59 -04:00
Brandon Thomas 9131ff7000 Merge PR #1166 2026-10-14 22:28:56 -04:00
Brandon ThomasandClaude Opus 5.5 da7534a2ed Batch 36: the slideshow template test drops the cached listing after writing a file by hand (flaky when the write lands in the listing's clock tick)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:28:53 -04:00
OneSlipsandClaude Opus 5.5 8308ff2034 Merge upstream main into pr/mask-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:06:26 +10:00
Brandon ThomasandClaude Opus 5.5 cfd14ea7c1 Batch 36: the Polish format catalog (#1100) lists the strings this batch's other PRs add, in English until translated; lockfile at 0.8.0 for the slideshow crate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:05:23 -04:00
Michael Anderson 2953b63576 Merge remote-tracking branch 'upstream/main' into feat/brush-shift-click
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 21:05:05 -05:00
Michael Anderson ca6077ab81 Merge remote-tracking branch 'upstream/main' into fix/1524-faces-after-relink
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-14 21:03:02 -05:00
Brandon Thomas b6eb559243 Merge PR #1053
# Conflicts:
#	crates/raw/src/vendor/nef.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-14 22:01:32 -04:00
Michael Anderson 453757773d Merge remote-tracking branch 'upstream/main' into fix/xmp-write-retry
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:59:59 -05:00
Brandon Thomas d8b52d9405 Merge PR #1105
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
2026-10-14 21:59:32 -04:00
Brandon Thomas aba04aaf5c Merge PR #1087
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/raw/coverage.tsv
#	docs/raw/evidence.tsv
#	xtask/src/main.rs
2026-10-14 21:59:31 -04:00
Brandon Thomas d3edca8000 Merge PR #1081
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/raw/coverage.tsv
#	xtask/src/main.rs
2026-10-14 21:59:29 -04:00
Brandon Thomas 6ab5e2b0d4 Merge PR #1108
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/raw/evidence.tsv
#	xtask/src/main.rs
2026-10-14 21:59:28 -04:00
Brandon Thomas d0d9aa7df9 Merge PR #1038
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
2026-10-14 21:59:17 -04:00
Brandon Thomas 381b57dd0b Merge PR #1096
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	xtask/src/main.rs
2026-10-14 21:59:10 -04:00
Brandon Thomas a19cbdb17b Merge PR #1045
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/target-app-parity.md
2026-10-14 21:59:07 -04:00
Brandon Thomas 89a51be77f Merge PR #1029
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/target-app-parity.md
2026-10-14 21:59:06 -04:00
Brandon Thomas 0fc3155859 Merge PR #1111
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-10-14 21:58:46 -04:00
Brandon Thomas a993758693 Merge PR #1106
# 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
#	docs/whats-new.md
2026-10-14 21:58:45 -04:00
Michael Anderson 6c5eacc839 Merge remote-tracking branch 'upstream/main' into fix/1392-browse-forgets-deleted
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:58:44 -05:00
Brandon Thomas 39502d482c Merge PR #1102 2026-10-14 21:58:43 -04:00
Brandon Thomas c99be924da Merge PR #1100 2026-10-14 21:58:43 -04:00
Brandon Thomas 28a6d97434 Merge PR #1062
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 21:58:37 -04:00
Brandon Thomas e0ed8c0270 Merge PR #1033
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-14 21:58:33 -04:00
Michael Anderson 9e597bf6dd Merge remote-tracking branch 'upstream/main' into fix/1627-d610-image-area
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:58:11 -05:00
Brandon Thomas ff66d188e8 Merge PR #996
# 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
#	docs/parity-checklist.md
2026-10-14 21:58:01 -04:00
Brandon Thomas 260a460c11 Merge PR #1019
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-14 21:57:59 -04:00
Brandon Thomas 58dc540cc0 Merge PR #1010
# Conflicts:
#	docs/gaps.md
2026-10-14 21:57:58 -04:00
Michael Anderson e423d55ea1 Merge remote-tracking branch 'upstream/main' into fix/1373-jpeg-optimal-huffman
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:57:31 -05:00
Brandon Thomas cbf3664e4f Merge PR #824
# Conflicts:
#	crates/engine/src/files.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
2026-10-14 21:56:53 -04:00
Michael Anderson 15becc9eb4 Merge remote-tracking branch 'upstream/main' into feat/nikon-z8-d5300-profiles
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:56:06 -05:00
Michael Anderson 88455a837c Merge remote-tracking branch 'upstream/main' into feat/nikon-dslr-lens-names
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:55:26 -05:00
Brandon Thomas 2138dc6e7d Merge PR #1009
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 21:55:05 -04:00
Michael Anderson a68da45fc3 Merge remote-tracking branch 'upstream/main' into fix/colour-fit-lens-geometry
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:55:01 -05:00
Brandon Thomas 0bef63b6eb Merge PR #974
# Conflicts:
#	crates/engine/src/cmd/mod.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/mcp.md
#	docs/parity-checklist.md
2026-10-14 21:55:01 -04:00
Brandon Thomas 8735468e81 Merge PR #966
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 21:54:57 -04:00
Brandon Thomas 4377bd4604 Merge PR #806 2026-10-14 21:54:55 -04:00
Brandon Thomas e78e165594 Merge PR #929 2026-10-14 21:54:54 -04:00
Brandon Thomas d837ab1455 Merge PR #912
# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
2026-10-14 21:54:53 -04:00
Brandon Thomas 6ce8d5a571 Merge PR #904
# Conflicts:
#	crates/ui-egui/locales/ru.json
2026-10-14 21:54:51 -04:00
Brandon Thomas 618b4c723a Merge PR #821
# 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-14 21:54:47 -04:00
Michael Anderson 35f347397d Merge remote-tracking branch 'upstream/main' into feat/nikon-z6ii-camera-profile
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:54:03 -05:00
Michael Anderson b70571448c Merge remote-tracking branch 'upstream/main' into feat/import-on-card
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 20:53:05 -05:00
MyrarcandOpenAI Codex cebaade144 CI-RUST195: keep strict Clippy clean on Rust 1.95
Move nested conditions into match guards and simplify equivalent Boolean
expressions without changing validation or short-circuit behavior. Keep
the explicit permitted Testkit dev-dependency arm in the layer checker.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-15 08:50:22 +07:00
Alex ChambersandClaude Opus 5.5 5d88e64783 LR-VIEW-BEFOREAFTER: animate both panes during click zoom
Translate the shared animated image rectangle into the Before pane for
whole-frame drawing, cached window tiles and input mapping. Translate the
final target separately so render requests stay stable during the motion.

Cover intermediate mesh bounds and final geometry in both layouts,
including clicks in either pane, return to Fit and cached region tiles.

Fixes #1821

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:47:56 +02:00
Marco Arnulfo 728471fcf5 Before/After: a click on the Before view zooms in on the point clicked (fixes #1742) 2026-10-15 03:47:31 +02:00
skylar taylor-barrick af4c5e102c fix(import): report and optimize Lightroom catalogs (#1406)
Task: LRC-LIB-CATALOG-IMPORT

Count offline records, Local promotions and virtual copies correctly. Keep destination, warnings and errors in a persistent import result, with Show in Library clearing filters. Preserve library-switch ownership, reimport identities, existing edits and one-step undo.

Avoid repeated missing-original probes and unnecessary staged-photo, catalog-operation and develop-patch copies. Preserve upstream phased progress and typed cancellation. Address Astra review findings with regression coverage.

Validated the combined main tree with all eight cargo xtask ci gates (3,462 passing test executions; zero failures), fresh 30k import/persistence/metadata checks, duplicate-free same-session and reopened reimports, and visually inspected headless screenshots. Historical performance comparisons apply to the f730805d production changes before main integration; native Windows import/GUI acceptance remains unqualified.

Generated with Codex.
2026-10-15 03:44:15 +02:00
Nathaniel LedfordandClaude Opus 5.5 62da9e8581 LRC-LIB-LIBFOLDERS: drag photos from the grid onto a Folders row to move them there
Photos dragged from the grid onto a Folders row move there on disk and in the library
(`photo.moveToFolder`: files and XMP sidecars, virtual copies follow, never overwrites, one undo
step), as Move Selected Photos Here in the row's menu does.
- The row lights up, and the label following the pointer says where they go: "Move 2 photos to
  “Porto”" (onto an album the same gesture only adds them, so the two read apart).
- A closed folder held under the pointer for 0.7 s opens, so a folder inside it can be reached.
- Refused, with the reason on release and a not-allowed pointer: a row that covers several disks,
  and Local photos (not in the library). Photos already there say "Already in “Porto”".
- A drop asks first, as Lightroom Classic does: "Move 2 photos to “Porto”?" (their files and
  XMP sidecars move on disk; Edit → Undo moves them back), with Don't ask again, kept in Settings
  as "Confirm before moving photos to another folder on disk" (on by default). The menu's Move
  Selected Photos Here, chosen by name, goes at once. A question left open while another library
  opens (the menu bar and agents reach past a dialog) moves nothing: photo ids are per library.
- The toast says how many moved and that Edit → Undo moves them back; the menu item says the same.
- Photos that leave the view leave the selection, so a Delete or edit next can't reach them unseen.

For NAS and network shares: the copy across volumes now streams (it read the whole file into
memory, a problem for a large raw or video) into a free hidden name that takes the photo's name only
once complete (a share dropping mid-copy left a truncated file under the photo's name, which a
retry reported as the photo already there). New engine tests cover a share going away:
Undo with the share offline or read-only is refused with the reason and kept until it can run,
nothing is deleted and the library stays linked; a connection lost partway through a move puts
back what moved; a file the share can't give back rolls the whole Undo back; and a real
move across devices where the machine has a second file system (`/dev/shm`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:58:08 -04:00
Alex ChambersandClaude Opus 5.5 206680fb22 LR-VIEW-BEFOREAFTER: animate both panes during click zoom
Translate the shared animated image rectangle into the Before pane for
whole-frame drawing, cached window tiles and input mapping. Translate the
final target separately so render requests stay stable during the motion.

Cover intermediate mesh bounds and final geometry in both layouts,
including clicks in either pane, return to Fit and cached region tiles.

Fixes #1821

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:24:48 +02:00
Michael Anderson e5c6e17bdb LR-MASK-BRUSH: Shift-click draws a straight brush stroke from the last one
A Shift-click with the brush paints a straight line from where the last stroke ended, tapering size, feather and flow when they changed in between, as one undo step. mask.brushStroke takes connect: true for it.
2026-10-14 19:11:34 -05:00
tecari0andClaude Sonnet 5.5 ba98dc66c9 Detail: snap the 100% view to whole screen pixels (#1683)
At 100% the photo's corner could sit half a physical pixel off, for
example in a 1279.5 pt canvas on a 2x display. The GPU then resampled
the photo with its bilinear filter and blurred it. On a 5152x7728 JPEG
the 100% view kept 68% of the fine detail.

fit_rect now rounds the corner to a whole physical pixel when the photo
is 1:1 with the screen. Other zooms resample anyway, and a pinch must
not jump, so they stay as they were. With the fix the same view matches
the original (71.4 dB PSNR, 100% of the detail).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:06:07 -07:00
Jason Baldwin f6c20bc0cc LR-LIB-REMOVE-ALBUM: translations use → for the menu path, not ▸ (#1805) 2026-10-15 01:16:27 +02:00
Jason Baldwin 70a12af9a2 LR-IMP-LOCAL: browsing a Local folder keeps or picks the active photo (#1447)
library.browse (engine and the UI's background read) switched the source to the
folder without touching the selection, so the previous view's photo stayed
active: Photo Grid marked nothing, and Detail, the histogram and the info
showed a photo the folder doesn't hold. Browsing now follows library.source's
rule through a shared Session::keep_active_in_view: the active photo stays when
the folder shows it, else the folder's first photo becomes active (also as the
background read's batches arrive).
2026-10-15 00:57:42 +02:00
Jason Baldwin 5c5370fc9b LR-LIB-REMOVE-ALBUM: removing a photo from an album keeps it in its folder (fixes #1805)
In an album the Delete key ran photo.delete, which moved the photo to
Recently Deleted, so it also left its folder views. It now removes the
selection from the album only (album.removePhotos), as in Lightroom; in a
smart album it changes nothing. Photo > Remove from Album shows in normal
albums with the Delete key; Photo > Delete Photo still deletes from the
library. album.removePhotos defaults to the album on screen and returns
{removed, album}.
2026-10-15 00:54:17 +02:00
Jason Baldwin b5169e907c LR-META-XMP: radial masks keep their inside/outside from Lightroom (fixes #1815)
crs:Flipped="true" is how Lightroom Classic writes an ordinary radial mask
(effect inside); it was read as our invert, so every imported radial applied
outside the circle. Established from Lightroom Classic 15.6's exports of
synthetic DNGs:

- MaskGroupBasedCorrections, Version 2: Flipped is the effect-inside flag;
  inverting is the component's MaskInverted.
- MaskGroupBasedCorrections without Version: inside whatever Flipped says
  (Lightroom rewrites both values as Flipped=true, Version=2), and its Feather
  is halved, as Lightroom rescales it on that upgrade.
- Legacy CircularGradientBasedCorrections Radial Filter: outside by default,
  Flipped=true (Invert Mask) is inside.

One mapping serves XMP sidecars, DNG-embedded XMP, presets and .lrcat catalog
import. LightCraft writes no crs: masks, so there is no writer to match.
2026-10-15 00:53:53 +02:00
NetterSilverandClaude Haiku 5.5 3080f71680 Keep crop finite when crop.set gets a huge rect
Found while testing crates/geom/src/crop.rs:59 (CropGeometry::constrained, via crates/engine/src/cmd/develop.rs:524 crop.set)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 00:44:54 +02:00
NetterSilverandClaude Haiku 5.5 072bcfeaf1 Fix crop.rotateAspect turning a zero-size crop into a NaN rect
Found while testing crates/engine/src/cmd/develop.rs:676 (crop.rotateAspect); also crates/geom/src/lib.rs:180 (Rect::fit_aspect)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 00:31:45 +02:00
Nathaniel LedfordandClaude Opus 5.5 d0a9ca09dd Buttons: one component with kinds, sizes and icons
Buttons were egui's plain one, a fill set by hand where one had to stand out (a dialog's confirm,
About's Discord link), with no hover or press shade on a filled one and no shared sizes.

- `crate::button::Button`: what it is for (`Kind`: Primary filled with the accent, Secondary
  with a neutral fill and a border, Danger filled red, Ghost with no fill until hovered), how
  big (`Size`: Small 24, Medium 30, Large 36 points, each with its padding, text and icon size),
  and what it shows (text, an icon before the text, or an icon alone, which takes a name for
  screen readers and its tooltip).
- Hover and press darken it; disabled (or in a disabled part of the window) fades it and takes
  no click, its tooltip or name still shown; a focused one shows an accent ring. A filled kind's
  fill is darkened as little as needed for white to read at 4.5:1 (the light themes' accent and
  the red would be too pale), in every state. Drawn within its own rectangle.
- Named for screen readers, registered for scripts and tests (`.widget(id)`). Its egui id comes
  from the widget id (salted with `.id_salt`, e.g. the dialog's kind, so the keyboard's place
  doesn't carry from one dialog to the next) or from its place in the ui, moved on so buttons
  with the same text side by side stay apart.
- Dialogs' footers (Cancel, the confirm; at least 72 points wide) and About's Discord link use
  it. The Export dialog's expected height in `tests_dialog_fit` grows by the 8 points of its
  footer's 30-point buttons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:57:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 f50372d808 Dialogs: the confirm button is on the right, filled when it is the next step
A design critique of the first run found the import review's "Import N Photos" a plain button at
the bottom left beside Cancel, though the welcome before it had taught that blue means go.

- Every dialog's footer is right-aligned: the confirm on the right, Cancel to its left, added in
  that order so the keyboard goes through them as the eye does. The row spans the width the
  window gives its content this frame, so it reaches the window's edge without widening it.
- The confirm is filled with the accent when it makes or brings in something (`confirm_style`);
  Delete and Remove, and an informational dialog's Close, stay plain. Which dialogs only show
  something is one list (`only_shows`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:11:36 -04:00
Guillem Hermida 053b7420fe LR-CROP-STRAIGHTEN: turning a crop shrinks it about its centre instead of swapping it for the largest crop (#1794)
CropGeometry::with_angle replaced a crop that no longer fit at the new
angle with the largest centred crop of its aspect. For a crop dragged in
from a corner (which still touches two edges) that happened at the first
fraction of a degree, so the crop jumped back to nearly the whole photo.

A crop that no longer fits now shrinks about its own centre, keeping its
aspect, to the largest size that fits there. The full photo, or a crop
an earlier turn shrank to the largest of its aspect, still follows the
largest crop and grows back towards 0 degrees (#1388). A crop whose
centre is off the turned photo is centred at its own size at most. No
crop the user made ever grows.
2026-10-14 23:09:11 +02:00
midasmatterhorn 0366116c76 ui: render the Show All Masks overlay in zoom windows
The loupe's window rendering already evaluates every visible mask's alpha
for an `Overlay::AllMasks` (its union is composed from the same alphas the
selected-mask overlay uses), but `overlay_supports_window` refused it, so
at 1:1 and closer "Show All Masks" fell back to a whole-frame render at
the zoomed size: slower, and its detail stopped at the frame render's
resolution. Allowing it makes the zoomed view a canvas-sized frame plus a
native window, as a single mask's overlay already is.

Tests:
- tests_preview_limit::an_all_masks_overlay_at_one_to_one_is_a_window
  (fails before: "native 6000: the whole-frame render is 6000")
- tests_window::an_all_masks_overlay_window_matches_the_whole_render
  (a window of the union matches the whole render's pixels, for every view)
2026-10-15 00:05:03 +03:00
Davit MatchakhelidzeandClaude Opus 5.5 fb2a460532 i18n(ka): bring the Georgian catalog up to date
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:38:48 +04:00
FredApps 6d493c9c73 Open the active grid photo with Enter 2026-10-14 22:23:01 +02:00
midasmatterhorn 66a202bbcf codecs: decode lossless (SOF3) JPEGs correctly
`header` accepts a lossless frame but the decoder routing only sent
CMYK/cropped/12-bit/non-interleaved files to jpeg-decoder: an 8-bit SOF3
file went to zune-jpeg, which refuses lossless, so a listed file could not
open at all. The files that did reach jpeg-decoder were wrong too:
jpeg-decoder emits its lossless samples native-endian (its `to_ne_bytes`),
so `from_be_bytes` swapped every sample (a 12-bit 2048 decoded as 8), the
raw `precision`-bit value was never left-justified into the 16-bit range
`Buf::U16` feeds onward, and a 3- or 4-component frame over 8 bits still
reports `RGB24`/`CMYK32` while holding two bytes per sample, so it was
wrapped as 8-bit data over double-length rows. `scale()` (a DCT size, not
a lossless resample) also cut a lossless frame to the top-left when a
thumbnail box was requested.

Every SOF3 file now routes to jpeg-decoder; lossless frames skip `scale`
(`finish` scales after); lossless samples are read native-endian and
left-justified; high-precision RGB/CMYK become `Buf::U16`.

Four hand-built SOF3 regression tests (12-bit gray, 12-bit RGB, 12-bit
CMYK, and 8-bit through the public `decode`); all four fail before the fix
(byte-swapped samples, U8-wrapped high-precision buffers, and the zune
refusal) and pass after. The crate's other tests stay green.
2026-10-14 23:12:32 +03:00
midasmatterhorn ea826b11cc engine: refuse negative indices instead of targeting element 0
`version.*`, `pointColor.delete` and `redeye.catchlight`/`redeye.delete`
parsed `index` with `f64_req(..)? as usize`. Rust's float-to-int cast
saturates, so `index: -1` became 0 and the bounds checks never saw a
negative: a caller sending `index: -1` silently deleted, restored or
renamed version 0 (or the first point-colour sample / red-eye correction)
and got `ok` back. `history.restore` already guards this and `spot_index`
uses `as_u64`.

New `index_req` helper in `cmd/mod.rs` (the same finite, non-negative
guard) is used by the five sites. Behaviour for valid indices is
unchanged.

Regression test `negative_indices_are_refused` creates a version, then
asserts `version.delete/restore/rename` with `index: -1` error and version
0 is untouched (before the fix the first one deletes it).
2026-10-14 23:12:32 +03:00
midasmatterhorn 3bb13afdea raw: check maker-note crop bounds with checked_add
A PEF or ORF maker-note crop with an origin of `u64::MAX` and a width of
1 made `x + width` wrap to 0, so the guard read it as inside the image and
`RawImage::check`'s identical comparison also wrapped: `validate()` passed
and `normalized()` then indexed past the sample buffer. Debug builds
panicked at the guard itself ("attempt to add with overflow").

The active-area check and both vendor crop guards (PEF AOC, ORF
ImageProcessing) now compare with `checked_add`, so a wrapping rectangle
is a `Corrupt` error at validate time. Exact fits still pass.

Regression test `an_active_area_that_wraps_past_the_image_is_corrupt`
builds such an image and asserts `validate()` and `normalized()` return
`Err` (fails before the fix with the overflow panic above).
2026-10-14 23:12:32 +03:00
midasmatterhorn b0241748ee cli: refuse non-object snapshot params instead of panicking
A script line for `ui.screenshot` without a `path` had its generated path
assigned with `params["path"] = …`: `serde_json`'s mutable index panics on
an array, a string or a number, and the CLI installs no `catch_unwind`, so
a script handed to the documented headless workflow aborted the process.
Only an object (or null, like an absent `params`) takes the path now;
anything else is a script error naming the line, like the existing handling
of a malformed line.

The regression test runs the real binary on such a script and asserts a
non-zero exit, no "panicked" in stderr and the `params` must be a JSON
object message (it fails before the fix with `cannot access key "path" in
JSON array`).
2026-10-14 23:12:32 +03:00
IanHollow 69346009d2 Packaging: advertise HEIF images and repair the AppStream screenshot
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-14 13:12:15 -07:00
Davit MatchakhelidzeandClaude Opus 5.5 9b9a96a1b5 i18n(ka): bring the Georgian catalog up to date
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:22:36 +04:00
Michael Anderson 8283ae1687 LR-PREF-GENERAL: Keep the Settings window in place when switching tabs
Anchor Settings at its top edge instead of the center and don't let it get narrower, so the tab row stays under the pointer.
2026-10-14 14:12:36 -05:00
Michael Anderson 1f87328c6d Never crash: Write preview cache files from one bounded writer (#1522)
Each deferred cache write started its own thread holding its image, with no limit. One writer thread per cache now takes them in turn, and a write is skipped once 128 MB is waiting.
2026-10-14 13:56:34 -05:00
Michael Anderson 6d080de447 Never crash: Check raw data size before allocating in PEF, TIFF tiles and ARW6 (#1512)
A compressed PEF too short for its size, an uncompressed tile claiming far more samples than it holds, and an ARW6 with a failed tile now get refused before the big buffer is allocated.
2026-10-14 13:56:34 -05:00
Michael Anderson 9ef40b9886 Commands: Refuse malformed photo ids instead of using the selection (#1506)
A wrongly typed id or ids fell back to the selection, or kept only the valid part of the list. Session::targets now returns an error for those, and callers pass it on.
2026-10-14 13:56:33 -05:00
Michael Anderson 30ec48d9b3 LR-META-XMP: Retry automatic XMP sidecar writes that failed
A sidecar write that failed because the folder was offline or read-only was only logged. Keep those photos waiting, write them again once their files are reachable, and tell the user.
2026-10-14 13:56:23 -05:00
NetterSilverandClaude Haiku 5.5 6d43479d38 Widen offline reverse-geocoding longitude search at high latitudes
Found while testing crates/geocode/src/lib.rs:172-195

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:52:01 +00:00
Davit MatchakhelidzeandClaude Opus 5.5 af0c1638fe i18n: Georgian (ka) interface language
Adds Georgian (ქართული, code ka, system tags ka / ka-GE / ka_GE) with
complete plain and formats catalogs, the Edit > Language command and the
Settings choice. Sentence-cased messages go through i18n::upper_first,
which leaves Georgian letters alone (upper-casing Mkhedruli would turn it
into Mtavruli). The Settings language choices wrap now that eleven
endonyms no longer fit on one line, and the face-model Remove button goes
through tr().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-14 22:50:28 +04:00
Michael Anderson ef3642c39f LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the columns past the image (#1627) 2026-10-14 13:44:58 -05:00
Michael Anderson 4352147910 Faces: Drop faces made from a photo's old file when it is replaced (#1524) 2026-10-14 13:19:22 -05:00
Marco Arnulfo 2df471b142 Top bar: in full screen the left buttons move into the traffic lights' space (fixes #1763) 2026-10-14 20:19:21 +02:00
Michael Anderson bb9d3d4022 Catalog and downloads: Don't write through a link at a temp file's name (#1502) 2026-10-14 13:13:27 -05:00
Nathaniel LedfordandClaude Opus 5.5 18e808c08f LR-BEHAV-EMPTY-STATES: an empty library leads to Import
A library with no photos opened on the Detail view, whose empty state said "Choose a photo in
the grid or filmstrip" while the grid was empty and hidden: the only screen that mentioned
import was off screen, and Import itself was nowhere but the File menu and its shortcut.

- An empty library (no photos, or only deleted or browsed ones) opens on the Photo Grid with the
  sidebar (Square Grid stays as it is), in the desktop app and in `lightcraft-cli snapshot`. The
  desktop app switches before it notes what its settings file holds, so the switch alone isn't
  saved.
- The grid and the Detail view show "Your library is empty" with Import Photos… (primary) and
  Import Lightroom Catalog… (not on the web) as buttons, and a line saying photos and folders
  can be dropped into the window, with the key Import Photos… is bound to now (`⌘⇧I` on macOS,
  `Ctrl+Shift+I` elsewhere, or a remapped one). Not while a folder is browsed under Local or
  Recently Deleted is shown: their photos aren't in the library but are on screen.
- The right-hand panel says "No photos yet / Import photos to get started" there instead of
  "Select a photo to edit".
- The top bar has an Import button beside Export in every library. The centred search field's
  reserve grows by both sides' share (620, and a 190 minimum), so the filter icon stays clear of
  the icons on the right at every window width, also with macOS's title bar in the window. The
  top bar's icons are named for screen readers, and a refused click says why in a toast.
- `empty_state` is `empty_message` with buttons that do the next step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:03:50 -04:00
Marco Arnulfo 6f9aae0445 LR-PROF-DROPDOWN: a long profile name no longer widens the Edit panel (fixes #1759) 2026-10-14 20:02:11 +02:00
BuonBredaandClaude Opus 5.5 4551790eab LRC-DEV-LOUPEOVERLAY: a layout image over the loupe (View ▸ Loupe Overlay)
Lightroom Classic's Loupe Overlay ▸ Layout Image: a PNG with transparency (a
cover's masthead and cover lines, a banner's safe areas) drawn over the photo
in the loupe, fitted and centred, following zoom and pan. Opacity and matte
(the photo darkened outside the graphic's box) in steps, ⌥⌘O shows / hides it,
the choice is saved with the UI state. The file is read off the UI thread; an
unreadable one is reported on the canvas. engine::export::read_graphic is the
watermark logo's reader, now shared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Mj2w3LwYjFhMHALZuFp2rU
2026-10-14 19:32:06 +02:00
MarekSpodBiedry 6e0880769a gpu: init-marker tests use their own scope counter so parallel device tests cannot join them (#1721) 2026-10-14 19:31:57 +02:00
LabDennis 1f4aaaec47 Fonts: finish Thai fallback delivery for desktop and web releases
Retain the complete reviewed change, including prior integration resolutions,
on current upstream.

#736: deliver Thai UI fonts in desktop and web releases

Use the merged craft-fonts input containing Noto Sans Thai across release,
FreeBSD and Nix builds. Retain the small Thai face in the wasm subset and
reject required release inputs that lack it. Exercise the reported Thai
folder name through all UI families and include original font licence texts
in Windows installers as well as portable packages.

Refs #736; completes delivery of the fallback added in #750.

Validation: UI tests, UI-only strict Clippy, 28 wasm checks, HEIF checks,
assets, layers, parity and formatting pass. All 21 unrelated workspace
failures reproduce with upstream Rust sources and the old font input;
existing Rust 1.95 metadata/engine Clippy findings remain unchanged.
2026-10-14 18:52:15 +02:00
Nathaniel LedfordandClaude Opus 5.5 2b635bb167 LRC-LIB-KEYWORDS: control requests wait for a frame's first pass; a hold lasts one pass
A pass drawn again after a discard still has the widgets of the pass
thrown away, so a control request answered there found a row where it no
longer is; it also added a 240 fps sample to perf.fps. Control requests
and the fps sample now run on a frame's first pass only. The Keyword
List's hold is kept for the pass right after it was asked for, so it
never moves a panel that was not drawn in between.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:41:48 -04:00
thanat0sandGPT-6-Sol medium 37b8c256ad LRC-LIB-PEOPLE: confirm ranked face suggestions in one batch (#1442)
Co-Authored-By: GPT-6-Sol medium <[email protected]>
2026-10-14 18:40:52 +02:00
Nathaniel LedfordandClaude Opus 5.5 648b1b5a93 LRC-LIB-KEYWORDS: the Keyword List holds its place as the Keywords box above it changes
Scrolled down a long Keyword List, ticking or unticking a keyword grew or
shrank the Keywords box above it (a row of chips, suggestions) and slid
the tree under the pointer. The panel now moves its scroll offset by the
same amount and draws the pass again, so the list stays put. It holds
only while the box's top is scrolled out of sight and the change does not
come from the box itself (the pointer on it, or typing in its field).

The offset is set on the scroll area as the redrawn pass starts, from the
offset the pass before started at: a scroll asked for during a pass lands
a frame later, and egui pulls a shrunken panel's offset back by the end
of a pass. Ticks clicked one after another (a script, a quick hand) then
land on the rows clicked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:38:02 -04:00
Claude 05352e18dd Merge storytold/lightcraft main into LR-MERGE-HDR (white balance)
Brings in batch 35 / v0.8.0. Code merged cleanly; conflicts only in docs: the LR-MERGE-HDR row keeps main's
lens-correction text plus this branch's white-balance item, and this branch's What's New entry moves to the
2026-10-14 section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-14 16:22:53 +00:00
Marco Arnulfo 8a218bddc1 Edit panel: the Reset tooltip shows the platform's shortcut, Ctrl+Shift+R on Windows and Linux (fixes #1751) 2026-10-14 18:22:51 +02:00
Claude ac17c580f5 Merge storytold/lightcraft main into LR-MERGE-HDR (frame order, clipped pixels)
Brings in #1423 (HDR merges keep the lens corrections), which landed in batch 35 / v0.8.0. hdr.rs merged cleanly:
the result's lens corrections come from the reference frame after the brightness ordering. Conflicts only in docs:
the LR-MERGE-HDR row keeps main's lens-correction text plus this branch's two items, and this branch's What's New
entry moves to the 2026-10-14 section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-14 16:22:37 +00:00
Nigel Ihza Firdausy 44629a0df1 test(ui): regress search text dragging without moving the app (#1646) 2026-10-14 23:15:16 +07:00
Nigel Ihza Firdausy b858d3e34a fix(ui): keep search-field text drags out of titlebar window gestures (#1646) 2026-10-14 23:14:57 +07:00
Marco Arnulfo 17e05759b4 DNG export and Convert / Copy as DNG write the colour a raw without matrices opens with (fixes #1745) 2026-10-14 18:08:53 +02:00
Marco Arnulfo 3d80ed531c Before/After: a click on the Before view zooms in on the point clicked (fixes #1742) 2026-10-14 17:52:13 +02:00
Gert Pellin fbe3810aa8 Metadata: list the Panasonic lens name in All Metadata (#1671)
photo.allMetadata listed no lens for a RW2: Panasonic writes no Exif
LensModel, and the maker note that names the lens is in the Exif of
the embedded JpgFromRaw. List that maker note tag (LensType, 0x0051)
as a row of a Panasonic group, the same name photo.inspect shows, for
a RW2/RWL and for a JPEG with the note in its own Exif.

Checked on the CC0 sample DMC-G80 P1000403.RW2: Panasonic / Lens Type
= SIGMA 60mm F2.8 DN.
2026-10-14 17:42:09 +02:00
tydude001andClaude Opus 5.5 0467c8904d packaging: RPM requires library sonames, so openSUSE can install it
LightCraft's RPM requires Fedora package names: `libxkbcommon`, `(vulkan-loader or libglvnd-egl)`.
openSUSE has no `libxkbcommon`, `vulkan-loader` or `libglvnd-egl` package, so zypper refuses it with
`nothing provides 'libxkbcommon'` (exit 4, which the ArtCraft Launcher shows as "error 4";
storytold/craft-launcher#341).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:41:07 -05:00
Michael Anderson e8818281d5 LR-IMP-LOCAL: Forget deleted files when browsing from the sidebar too
The sidebar reads the folder on a worker thread and skipped the cleanup library.browse does, so the fix for #1392 didn't reach the app.
2026-10-14 10:40:41 -05:00
midasmatterhorn 14d321c107 catalog: slice captured dates on char boundaries so non-ASCII dates don't panic 2026-10-14 18:20:34 +03:00
Claude c472f52c84 Lightroom import: raise catalog/WAL size limit from 1 GiB to 4 GiB
Reserve the exact file size before reading so a large catalog does not
need twice its size in memory while the buffer grows.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013TGi6EFawAr2RPfqXsoCrQ
2026-10-14 14:51:26 +00:00
Brandon ThomasandClaude Opus 5.5 3b4279fc24 FreeBSD CI: three more flaky tests made deterministic
import_reads_files_off_the_ui_thread_and_can_be_cancelled: the probe was
a 150 ms sleep judged by a 300 ms wall-clock frame budget, which the
loaded FreeBSD QEMU VM can miss. It now waits on the sleeping-NAS gate of
tests_offline, which can also let a counted number of reads through:
frames must go on while every read is held (a watchdog records a frame
that waited), one batch is let through and joins the catalog, then
Cancel and wake: the files being read finish and nothing new starts.

compare_at_one_to_one_gets_a_sharp_window_per_pane ("both originals stay
decoded"): the media cache takes its budget from the process-wide memory
budget, which settings_dialog_by_keyboard_and_clicks sets to 1 GB for a
moment (and left there when it failed on FreeBSD). That leaves a 512 MiB
share, too small for two 24 MP originals (288 MB each), so one pane's
was evicted. compare_at now gives its session the default 768 MiB share;
LIGHTCRAFT_MEMORY_MB=1024 reproduces the failure without this.

dropping_the_lock_frees_the_library_while_another_thread_spawns_processes:
"the spawning thread never got a child off the ground" when a spawn took
longer than the 2 s of lock cycles. The cycles now go on until at least
5 children were spawned during them (at most a minute).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:36:53 -04:00
Marco Arnulfo f360ad949d render --settings applies the preset in a .lcpreset, and refuses files it can't read (fixes #1730) 2026-10-14 16:25:30 +02:00
dev-null-undefinedandClaude Opus 5.5 1e55d36e36 Perf: DNG Deflate tiles at level 1, 4.8–7× faster writes for 3–12 % more bytes
Deflate-compressed DNG tiles are written at zlib level 1 instead of 6. Measured on
Sony A7R VI files (67 MP), 4 threads, best of 3:
- Photo Merge DNG (half floats, 3 channels): 3.88 → 0.55 s, 265.7 → 274.8 MB (+3.4 %)
- raw export with Deflate (16-bit CFA): 0.89 → 0.18 s, 96.7 → 103.0 MB (+6.5 %)
- rendered DNG: 4.60 → 0.69 s, 239.0 → 267.1 MB (+11.8 %)
Lossless either way; both written files decode back bit-exactly. Lossless-JPEG
DNGs (the default for convert/import) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:58:37 +02:00
Brandon ThomasandClaude Opus 5.5 16dbb90499 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:34:55 -04:00
Lucas LepskiandGPT-6.1 Sol 7984667269 UI-ARROWS: reconcile chevrons with current upstream UI
Keep the HDR histogram and SDR/HDR legend, virtualized sidebar rows and current preset grouping while retaining shared chevrons and inset interactive clipping buttons. Apply shared submenu arrows to the new album import menus. Drop the earlier inpainting compatibility change now present upstream.

Validation: formatting, strict native Clippy and HEIF checks passed; 811 compiled UI tests passed; native SDR and HDR screenshots checked. Full workspace CI is blocked by all_masks_overlay_shows_every_visible_mask_at_once, reproduced unchanged on clean upstream b3d392b4. Publish this prepared rebase at the contributor’s explicit request with that baseline failure documented.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-10-14 15:27:56 +02:00
G6billandCopilot App 877ef342dd MCP-CONTROL: validate remote reply framing and IDs (#1716)
The connect-mode MCP client accepted any newline-less or wrong-id reply as
the answer to its request. Require a complete newline-terminated reply of
at most 64 MiB, the matching integer id, boolean ok and a result or string
error; otherwise close the connection and report an unknown outcome without
resending. Idless pre-dispatch refusals from the control server still work.

Co-authored-by: Copilot App <[email protected]>
2026-10-14 15:25:13 +02:00
Brandon ThomasandClaude Opus 5.5 73c61aae44 FreeBSD CI: two flaky ui-egui tests made deterministic
settings_dialog_by_keyboard_and_clicks: the Performance tab's GPU status
hint reads process-wide GPU state, which other tests flip through
app.gpu. Without a GPU driver (the FreeBSD VM) the reason is a long wgpu
message that wraps to two lines, while "disabled by the GPU rendering
preference" is one, so the controls below moved by 20 px between the
frame that located "button:settingsMemory-2" and the frame that clicked
it. The status is now one truncated line with the full text on hover;
gpu_status_never_moves_the_performance_controls checks the buttons stay
put (it fails without the fix when no Vulkan driver loads).

offline_originals_never_block_a_frame: the slow NAS probe was a 400 ms
sleep judged by a 300 ms wall-clock frame budget, which a loaded QEMU VM
missed (1.06 s). The probe now waits on a gate the test opens; frames
must finish while it is closed (a watchdog records any frame that had to
wait), so no timing budget is needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:04 -04:00
aGiuliano eac616b542 Merge branch 'main' into fix/Chinese_Japanese_Text_warning 2026-10-14 14:53:25 +02:00
Jason Baldwin 3aa6b3ba31 Merge slideshow-templates into slideshow-export (origin/main: batch 35)
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 14:42:03 +02:00
Jason Baldwin 5f3bdbcb73 Merge slideshow-titles into slideshow-templates (origin/main: batch 35)
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 14:39:47 +02:00
Jason Baldwin 7919c1471d Merge slideshow-overlays into slideshow-titles (origin/main: batch 35)
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 14:37:20 +02:00
Jason Baldwin f776d98167 Merge slideshow-composer into slideshow-overlays (origin/main: batch 35)
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 14:35:02 +02:00
Jason Baldwin 2b766142bc Merge origin/main into focus-point (tracker header/summary/change log, whats-new entry moved under 2026-10-14) 2026-10-14 14:33:49 +02:00
Michael Anderson ead24aded9 LR-PROF-CAMERACOLOR: Add a bundled Nikon D5300 camera profile
Fitted with lightcraft-cli calibrate to 264 of my own D5300 photos.
2026-10-14 07:32:56 -05:00
Michael Anderson 975b08c334 LR-PROF-CAMERACOLOR: Add a bundled Nikon Z 6II camera profile
Fitted with lightcraft-cli calibrate to 296 of my own Z 6II photos.
2026-10-14 07:32:56 -05:00
Michael Anderson 2bc2f60277 LR-PROF-CAMERACOLOR: Add a bundled Nikon Z 8 camera profile
Fitted with lightcraft-cli calibrate to 261 of my own Z 8 photos.
2026-10-14 07:32:56 -05:00
Michael Anderson c854e172e4 LR-PROF-CAMERACOLOR: Match the camera look against lens-corrected JPEGs
The fit compared the uncorrected raw with the corrected JPEG. It now warps the raw with the file's own lens correction when that lines up better.
2026-10-14 07:32:47 -05:00
Michael Anderson 441f46683a LR-IMP-DEVICE: Show the import review when a card is connected
Adds an option under Settings > Import > Cameras and Cards that opens Import from Device when a camera or memory card shows up. It's off by default, and a card that was already in at startup doesn't ask.
2026-10-14 07:32:47 -05:00
Michael Anderson 1acbf88661 LR-META-INFO: Show the lens for Nikon DSLR raws
They don't write a lens name, so read the range from the maker note's Lens tag.
2026-10-14 07:32:46 -05:00
Michael Anderson 02d5d6bc03 LR-MERGE-PANO: Keep panoramas at full resolution when memory allows (#1455)
Drop the 40 MP default and free each photo's pixels once it's projected.
2026-10-14 07:32:45 -05:00
Michael Anderson b0b86aca84 LR-BEHAV-GPU: Turn GPU rendering off after the GPU is lost while running (#1337)
Report it as LightCraft stopping instead of the window failing to open, and start on the CPU next time.
2026-10-14 07:32:44 -05:00
Michael Anderson 697010d520 LR-IMP-LOCAL: Forget untouched records of deleted files when browsing a folder again (#1392)
Rated, flagged or edited photos stay and show as missing.
2026-10-14 07:32:43 -05:00
Jason Baldwin b60c74a6d7 Merge slideshow-playback into slideshow-composer (origin/main: batch 35)
# Conflicts:
#	crates/engine/src/import.rs
#	crates/engine/src/media.rs
#	docs/parity-checklist.md
2026-10-14 14:31:53 +02:00
Jason Baldwin 3aaadd1456 Merge origin/main into slideshow-playback (batch 35: tracker summary refreshed, slideshow rows kept) 2026-10-14 14:29:54 +02:00
Michael Anderson e0690b5f63 LR-EXP-QUALITY: Use optimized Huffman tables for JPEG exports (#1373)
Build the tables from each image's own symbol counts. Same pixels, smaller files.
2026-10-14 07:28:30 -05:00
Nathaniel LedfordandClaude Opus 5.5 d2523e8544 Preview cache: cached thumbnails are no longer taken for the person's photos
The thumbnail cache wrote .jpg files into the library's thumbs folder, which is usually inside
Pictures: photo galleries listed every thumbnail as a photo (issues #610, #1066), and every read
brought the file's date forward, so each launch made them all look new.

- Cache files are named <32 hex>.lcthumb (JPEG data as before). A .jpg of before is taken over
  under the new name when it is asked for; the rest when the cache is tidied, which the engine does
  once per cache folder, on a thread of its own with a row in the activity stack that shows its
  progress and can stop it. Nothing is rendered again.
- Reading writes nothing: what was read is remembered for the session (a full cache lets it go
  last), and only a file that went unused for a week has its date brought forward.
- A folder that holds nothing but the cache gets a CACHEDIR.TAG, when the cache makes it and when
  it is tidied; one that also holds somebody's files is not tagged (issue #98).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:26:17 -04:00
Aleksandr Batyrev 11fb6f3156 chore(contributors): add Aleksandr Batyrev's real-name credit 2026-10-14 17:25:42 +05:00
Andrew FrahnandClaude Opus 5.5 57375eb4b5 Colour grading wheels: vivid sRGB rim fading evenly to neutral
Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:23:25 +11:00
Nathaniel LedfordandClaude Opus 5.5 afacec1747 LR-BEHAV-WINDOW: the window opens at the size it was left at, zoomed if it was
The desktop app opened at 1600 x 1000 every time: a resize, or a zoom by a
double-click on the top bar, was forgotten at quit.

The size the window rests at and whether it is zoomed (macOS) / maximized are
kept in ui.json (`window: {size, zoomed}`) and the window is built from them.
A zoomed window keeps the size it goes back to. A size counts once the window
has rested at it for half a second, so the sizes a resize or the zoom
animation passes through do not; one given just before quitting is taken at
exit. Full screen and the window's place on the desktop are not remembered.

Windows and Linux say whether a window is maximized. On macOS egui-winit only
refreshes that at launch and when the app zooms the window itself, so there
the window's size is gone by as well: zoomed when the system said so or the
window was seen to come to fill its screen, and for as long as it keeps that
size. Whatever is in doubt is a window of the size it has, which opens as
large as it was left.

A `window` in the settings that is not one reads as none and costs no other
setting.

Run on macOS with one display; the other platforms follow from reading winit
and egui-winit, so the tracker row is partial.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:55:41 -04:00
Nathaniel LedfordandClaude Opus 5.5 6bb52f09f6 LRC-LIB-KEYWORDS: what the check of the review fixes found
- An offer taken asked the box to start a new edit on the next frame; a
  box not drawn then (its panel switched away at once) took the cursor
  whenever it was next drawn. The request holds for one frame.
- Return on the frame the box scrolled out of sight still took the
  highlighted offer, which was no longer drawn: it gives nothing.
- In a window with room for the offers on neither side of the box they
  covered it: they go to the side with more room and show as many lines
  as fit there, the highlighted one among them.
- Ukrainian says "suggestions" as the panel does; four clearer wordings
  (uk, ru, de, ja).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:52:51 -04:00
Nathaniel LedfordandClaude Opus 5.5 c2c85f5d97 LRC-LIB-KEYWORDS: what two independent reviews found in the Keywords panel
- "+N more" was for the whole session: it is for the selection it was
  asked of, and another selection's long list is short again.
- A keyword two photos spell differently was offered twice under one
  widget id, and a click on it gave nothing: it is offered once.
- The offers followed the box out of the panel when it scrolled away and
  covered it near the bottom of the window: they are not drawn while the
  box is out of sight, and go above it where there is no room below.
- The highlight was a line number, so a change of the library moved it to
  another keyword, which Return then gave: it stays with its keyword.
- Esc after an offer was taken brought back keywords just added; Shift or
  Alt with the arrow keys moved the highlight instead of the text cursor;
  a refused add lost the typed text without a word.
- A name the library lacks was looked up in the library twice per change
  of the text: once.
- A wrong value under keywordChips or foldedSections discarded the whole
  settings file: it is read as the default.
- Groups follow the tree ("Places > Italy" right after "Places"); a heading cut
  short says its whole name on hover; the number of chips measured for a
  shortened list follows the box's width; the selection is asked for once
  a frame.
- The nine new plain messages are translated in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:52:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 4ce0aad79c LRC-LIB-KEYWORDS: the Keywording box says what a removal touched
With several photos selected the box says how many its keywords are of.
A chip's x is a target as tall as the chip and 22 pt wide (it was 14 pt
square) that lights up on its own, and the chip is 4 pt narrower for it.
Taking a keyword off several photos, by the x or the chip's menu, says
which keyword left how many photos and that Edit > Undo puts it back; a
refused removal says why. Chips and their x show the keyboard focus, and
the Add keyword box says in its tooltip that commas separate keywords.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:52:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 5ca5e0547c LRC-LIB-KEYWORDS: the Add keyword box offers the library's keywords under itself
What matches the text being typed (after the last comma) is offered right
under the box instead of in the Suggestions block further down: Down / Up
move through the offers, Return or a click gives the highlighted one (with
the keywords typed before it) and leaves the cursor in the box. With none
highlighted, Return gives what was typed, as before.

A name the library doesn't have is announced as a new keyword, with the
keyword it would go inside, before Return creates it.

The offers have a cache of their own: they and the suggestions are both
asked for on every frame of typing, and each looks through the library's
keywords.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:52:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 366e6891a0 LRC-LIB-KEYWORDS: the Keywords panel folds its sections and can group the selection's keywords
The titles of Keywords and of the Keyword List fold their sections shut
(kept in the UI state as foldedSections), so the list of every keyword is
reachable however many keywords the selection has.

Settings > Interface > Keywords of the selection > Grouped by parent
(off by default): keywords inside the same keyword share a line naming
it, their chips say only what is below it and sit side by side, a list
of more than ten lines stops after eight with "+N more", and the
suggestions say the keyword's own name.

A selection without keywords says "No keywords yet", as the other two
views of the box do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:52:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 43ba500c6e PERF-EDIT: a smart album's count follows an edit, asking only the photos it touched
The left panel shows a count beside every album. Each catalog revision had
every album counted again, and a smart album's count asks every photo of
the library whether its rules hold: one pass over the library for each smart
album after each keyword, rating or slider tick (about 25 ms an album in a
library of 67,000 photos).

`AlbumTally` (`crates/catalog/src/tally.rs`) keeps the counts and, for each
smart album, which photos it holds. After a small edit each smart album asks
only the photos the edit touched (`Catalog::touched_since`); an album of
chosen photos is counted again only when a photo came, went or was deleted.
Counted from every photo as before: new rules, a change to an album, a stack
or the keyword list, an edit to more than 256 photos, another library, and
each new minute while an album asks about "the last N days".

What an album remembers is in proportion to the photos it holds: a bit a
photo id while ids are close together, a set when they are far apart.

The frame after a keyword in that library, both side panels open: about
54 ms -> 27 ms (156 ms before #1660).

Tests: 900 random edits against eight smart albums (keywords, flags,
ratings, labels, sliders, capture dates, file names, an album of chosen
photos) and an ordinary one, the followed counts compared with
`album_count` after each; a delete followed, three hundred photos counted
whole; the members' bits at the edges and for ids far apart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:46:42 -04:00
Nathaniel LedfordandClaude Opus 5.5 5f1037a192 PERF-EDIT: an edit is followed from what it touched, not worked out from every photo
Everything derived from the catalog was keyed on its revision, which every
op raises. So each edit (a keyword, a rating, every tick of a slider) had
the grid's list queried and sorted again from every photo, the source
counted by a second full query, and the side panels tally the whole library
again. In a library of 67,000 photos the frame after one keyword took about
156 ms with both side panels open (an idle frame: 2 ms).

The catalog now remembers which photos its latest ops touched and what of
them they changed (`Catalog::touched_since`, `aspects_since`, `Aspects`). It
answers "not known" for an op that changes more than photos of its own (an
album, a stack, the keyword list), for changes too long ago, and for a
revision it did not make itself (a library just loaded).

- The view (`Session::visible`): when the same view is asked again after a
  small edit, the photos the edit touched are taken out of the list it had,
  asked about again, and put back at their place in the order
  (`Catalog::follow`). 37 ms -> 0.6 ms. Worked out whole as before: more
  than 256 photos touched, a change that is not photos' own, an album in its
  own order, Recently Added, Missing Photos, and a view that asks about "the
  last N days" (it changes with the clock too).
- The source's count: taken from the list when no filter is set, else
  counted without sorting (`Catalog::count`). 37 ms -> 0.
- The side panels (`Caches`): the folder tree, keyword tree, date groups,
  people's names and library counts are kept through an edit that changed
  nothing they read. A rating or a slider changes none; a keyword leaves the
  folders, dates and counts.

The frame after a keyword: about 156 ms -> 54 ms with both side panels
open, 71 ms -> 2 ms with the grid alone (`tests_view_cost.rs`, a synthetic
library, run with --ignored --nocapture).

Still paid on each edit: the count beside every album and smart album in the
left panel (about 25 ms for a smart album there), and the filter bar's lists.

Tests: 1,440 random edits of every photo-local kind across sources, sorts,
filters and the hold, each compared with the list worked out whole; 600
random edits comparing every kept tally with a fresh one; the clock; the
record itself, with its failure paths. `ui.inspect` -> `perf` reports
`viewFollowed` and `viewWorkedOut`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:46:30 -04:00
Nathaniel LedfordandClaude Opus 5.5 540a281ccd PERF-SYNC: a Synchronize Folder scan reads off the folder's listing whether a photo and its sidecar are there
The scan asked the disk about every library photo in the folder: is its
file there, and has it a sidecar under any of four names. On a network
share each question is a round trip, and the scan had just listed the
folder. Measured on a hard disk over SMB, for unchanged folders of 1,600
to 2,400 photos: 4.6 to 7.1 s of questions after a listing of 4 to 5 s.

- The walk hands each folder's listing over (`walk::files_in_noting`:
  every entry's name and kind, hidden ones too, and whether that is all of
  them). The scan walks the folder once, then checks the photos beside
  the probing of new files.
- `sync::Listing` answers "is it there" / "is it a file" where that holds
  on any file system: a name the folder lists is there; a plain ASCII name
  is not there when the folder was listed to its end, all its names are
  ASCII and none is that name in another case. Everything else is asked
  of the disk as before (a link, a name Windows reads another way, a
  folder that was not walked, and every name not listed in a folder that
  holds a name outside ASCII).

Now under 0.05 s for those folders. Two things differ on purpose: a file
the folder lists is there though it can't be opened by its path (a folder
that may be listed but not searched, an over-long path), where such
photos were listed as missing; and the answers are the folder as the
walk saw it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:45:43 -04:00
Nathaniel LedfordandClaude Opus 5.5 73b9cc3708 LR-LIB-SMARTALBUM: a view can hold its photos until it is refreshed
A view is asked again after every edit, so a photo that an edit makes stop
matching it leaves the grid at once and every photo after it moves up: a
keyword given in a smart album of photos without keywords takes the photo
away from under the cursor, and in a random order, with no landmarks, the
shift reads as a new shuffle (the seed does not change).

View > Hold View Until Refreshed (`library.holdView`, off by default, kept
with the library's preferences): a photo the view shows stays in it when an
edit makes it stop matching a smart album's rules, Picks or the filter bar.
View > Refresh View (`library.refresh`, also in the sort menu while the hold
is on), Reshuffle, another source, filter or sort, or new rules for the
smart album shown, shows what matches now.

- Photos that start matching still appear at once.
- A photo that is deleted, or taken out of the album or folder shown, still
  leaves at once: a held photo has to be where the view looks
  (`Catalog::query_keeping`, `within`).
- A source with no filter over it holds nothing and is queried as before.
- A refresh keeps the seed.
- The grid's count no longer reads "3 of 2" when a held view shows more than
  now match its source.

Each edit still asks the whole library again; that cost is not addressed
here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:41:44 -04:00
dev-null-undefinedandClaude Opus 5.5 857ffcd41c LR-MERGE-HDR: 🟡 after testing on real brackets; gap 29
Tested on real Sony A7R VI brackets (67 MP): the merged radiance agrees with every frame within ~1 % in all three
channels and the measured exposure steps match EXIF within 0.03 EV, and the black-hole stars and the merge memory are
fixed. What keeps the row 🟡 goes to gap 29: AI Denoise doesn't run on merged (linear) DNGs as Lightroom's does, JPEG
brackets have no camera response curve, and every frame is held as 32-bit float (~0.86 GB per 67 MP frame). Merge area
100 % → 88 % in target-app-parity.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 13:37:30 +02:00
dev-null-undefinedandClaude Opus 5.5 b52762797f LR-MERGE-HEADLESS: HDR for Each Bracket Set
Photo ▸ Photo Merge ▸ HDR for Each Bracket Set (`merge.hdrBatch`) merges many brackets in one go with the last HDR
options, as Lightroom merges a selection of stacks: one HDR per stack among the photos, then per bracket among the
unstacked ones (`Catalog::bracket_sets`: photos taken less than about a second after the previous one's shutter
closed, at exposures at least ½ EV apart; bursts at one exposure and earlier merge results are left out). The app runs
the merges one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); the
command runs them inline for the CLI and MCP (`lightcraft-cli merge hdr-batch FILES…`) and reports merged and failed
sets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 13:37:13 +02:00
Andrew FrahnandClaude Opus 5.5 971ad25b74 docs: leave upstream's remaining-hours figure unchanged (the estimate was ours)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 22:22:15 +11:00
Marco Arnulfo 0cf062f508 LR-PROF-BROWSER: long profile names are cut short in a narrow panel (fixes #1634) 2026-10-14 12:58:40 +02:00
dev-null-undefinedandClaude Opus 5.5 6fb0d22326 LR-MERGE-HDR: decode full-size merge frames one at a time; AHD writes its tiles straight into the picture
A 7-frame bracket of 67 MP raws ran out of memory: the merge decoded the frames four at a time, and the AHD demosaic
collected every tile before copying them into a second full-size picture. Full-size frames now decode one after the
other (the demosaic is parallel inside; previews stay parallel), and AHD fills the picture by rows of tiles. Peak memory
of 67 MP HDR merges: 3 frames 4.8 → 3.8 GB, 4 frames 6.3 → 4.7 GB, about the same time; AHD output is pixel-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:56:59 +02:00
dev-null-undefinedandClaude Opus 5.5 a142179f20 LR-MERGE-HDR: keep the reference's clipped highlights where other frames moved; deghost catches ghosts in dark skies
A star moving across a night bracket became a black hole: where the reference was clipped, frames in which the star
had moved read the dark sky and replaced it. Such frames are now left out there (a clipped reference pixel means at
least that bright). Deghost compared ≤ 1024 px versions and, in dark areas, let the brighter frames predict
themselves, so their ghosts looked consistent; it now uses ≤ 2048 px maps (same feather share) and trusts the
reference down to 0.1 % of its clip. Aligned frames are warped one at a time, each replacing its original, instead of
all copies at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:56:59 +02:00
Marco Arnulfo 0f398f6dba Edit panel: the histogram clipping buttons stay clear of the hovered scroll bar (fixes #1640) 2026-10-14 12:33:09 +02:00
Sergio PereaandClaude Opus 5.5 3c907e0c9d fix(export): a metadata/sharpen/conflict value that passes validation is the one exported
`ExportOptions::validate` accepted `metadata`, `sharpen`, `sharpenAmount` and
`conflict` case-insensitively, but `from_json` read them with serde's
exact-case names and silently fell back to the default when that failed.
`{"format": "jpeg", "metadata": "NONE"}` therefore passed validation and
exported with `MetadataPolicy::All`: GPS, location and camera data went out
in a file the caller asked to carry none. MCP, the control channel and the
CLI all go through `from_params`, so all were affected.

Each of the four enums gets a case-insensitive `parse` (the pattern
`HdrCurve::parse` and `JxlEffort::parse` already use), and both `validate`
and `from_json` call it, so validation and conversion can no longer
disagree. Accepted inputs, error messages, defaults and `to_json` output are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:08:36 +02:00
Aleksandr Batyrev aa62ba05f9 fix(rawinfo): return failure for unreadable or invalid inputs 2026-10-14 14:16:35 +05:00
Gert Pellin f9c97ab57b Metadata: read the Panasonic lens name from maker note tag 0x0051 (#1671)
Panasonic writes no Exif LensModel. The lens name is in its maker note
(LensType, 0x0051), and a RW2 or RWL keeps that maker note only in the
Exif of its embedded JpgFromRaw (IFD0 0x002E), not in the raw's own Exif
IFD. When LensModel is absent, read it from the maker note, and for a
raw from the JpgFromRaw's Exif.

Checked on the CC0 samples DC-GX9 gx9_1_1.RW2 (LUMIX G 25/F1.7) and
DC-S5 P1047510.RW2 (LUMIX S 85/F1.8), also through the sparse header
plan an import uses.
2026-10-14 11:09:25 +02:00
Andrew FrahnandClaude Opus 5.5 78b014fb79 Merge upstream/main into pr/pen-pressure
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:04:19 +11:00
Andrew FrahnandClaude Opus 5.5 61ec5c596c Docs: restore upstream's header notes in gaps and parity checklist
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:02:09 +11:00
Andrew FrahnandClaude Opus 5.5 6d5c19d288 Merge upstream/main into pr/canvas-keyboard
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:00:12 +11:00
Andrew FrahnandClaude Opus 5.5 f625c74c7a Faces: whole networks on the GPU, batched faces, CPU-only switch, paced GPU use
Activations stay on the device between layers (net/program.rs lowers the graph; every
operator is a WGSL kernel, one submission and read-back per run); faces of a photo go
in batches of up to 16; Settings > Faces shows each model's device and a CPU-only
switch (faces.gpu); the scan's pace limits GPU use; the GPU check is an activity row.
YuNet 58 -> 21 ms, SFace 40 -> 9.6 ms, AuraFace 295 -> 71 ms (heavily loaded machine).

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:43:44 +11:00
Claude 3ae306ecb3 LR-IMP-FORMATS: decode EOS R6 Mark II C-RAW with adaptive QP up to 175
EOS R6 Mark II C-RAW QP maps reach 172 and 175; crx.rs accepted 131-168,
so those files opened as their preview ("Canon CRX adaptive QP value 172").
Adaptive QP now goes to 175 (quantization value 51, step 228), the highest
seen in any file; uniform subband values still stop at 44.

Checked black-box against the camera JPEGs on private files: 144 R6 II /
R7 C-RAW files decode to identical hashes where they decoded before, the 3
failing ones now decode (green follows the JPEG at r 0.98), fine detail per
QP shows no jump at 172/175, 100 % crops are clean, and 1,492 files sampled
from a 35,796-file library decode with max QP 175. No third-party decoder
source consulted. Bumps RENDER_CACHE_VERSION to 63; cr3.md and What's New.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YSGFEf87ba1zKJZb9uzddK
2026-10-14 08:41:52 +00:00
Marco Arnulfo 8628c160df Loupe: the info overlay's shadow is dark, not a second white copy of the text (fixes #1690) 2026-10-14 10:33:43 +02:00
Andrew FrahnandClaude Opus 5.5 e5efaea6a8 Merge upstream/main into pr/file-writes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:23:00 +11:00
Andrew FrahnandClaude Opus 5.5 61117e53e1 Merge upstream/main into pr/more-verified-bodies-2
Docs conflicts re-applied on upstream's text; coverage numbers regenerated
(unchanged: 334 verified, 844 unverified, 232 preview); duplicate rows in
the raw-parity summary table removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:21:03 +11:00
Andrew FrahnandClaude Opus 5.5 d598c304aa Merge upstream/main into pr/zoom-detail
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:20:53 +11:00
Andrew FrahnandClaude Opus 5.5 065818f524 Merge upstream/main into pr/more-verified-bodies
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:19:31 +11:00
euisuhandOpenAI Codex 707aa5aa92 fix(mcp): stop stdio cleanly after output failure
Wake the serving loop after protocol output fails and preserve the actual writer error. Use one process-owned stdin worker so the CLI can finish normal backend cleanup without waiting for client EOF, and terminate the output worker even when a progress hook is retained.

Closes #1527

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-14 17:02:16 +09:00
Andrew FrahnandClaude Opus 5.5 bd2ad63b26 Merge upstream/main into pr/more-verified-bodies-3
Resolve doc conflicts in gaps, parity-checklist and raw-parity; coverage
numbers regenerated with docs/raw/coverage.py, parity summary refreshed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:02:15 +11:00
Andrew FrahnandClaude Opus 5.5 befd25f2a3 Merge pr/raw-erf into pr/raw-mef
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:00:57 +11:00
Andrew FrahnandClaude Opus 5.5 0bc2f79bb5 Import: find sidecars whose photo extension or .xmp differs in case
On a case-sensitive file system, `DSC_1.nef.xmp` or `DSC_1.Xmp` beside `DSC_1.NEF` was never
found, so the photo came in with only the file's own metadata (its IPTC and keywords) and lost
the sidecar's rating and crop. The lookup now tries the photo extension in lower and upper case
and `.xmp`/`.XMP`/`.Xmp`; Move takes such a sidecar along.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:56:21 +11:00
Matthew James SivakandClaude Opus 5.5 411f1fce19 Merge origin/main into fix/330-sharp-drag-preview (batch 35)
docs/parity-checklist.md: main's header and summary (regenerated) with this PR's LR-VIEW-ZOOM note and history rows. Also brings in main's on_exit, as_chunks and test fixes, so the branch builds and passes clippy again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:56:04 +02:00
Andrew FrahnandClaude Opus 5.5 50cb797872 Merge upstream/main into pr/raw-erf
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:48:38 +11:00
Matthew James SivakandClaude Opus 5.5 b01b09b416 Merge origin/main into develop-solo-mode (batch 35)
docs/parity-checklist.md: main's header and summary (regenerated) with this PR's Solo Mode note and history row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:45:38 +02:00
Andrew FrahnandClaude Opus 5.5 5f1464170e Merge upstream/main into pr/more-verified-bodies-4
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:35:33 +11:00
Val EnfysandClaude Opus 5.5 f29a340fa6 LR-IMP-FORMATS: recheck stale preview-only raws when a library opens (#662)
A raw imported while its format couldn't be decoded kept preview_only until a
manual Reload, so new decoders in a release never reached existing photos.
When a library opens in another app version, its preview-only photos whose
stamp differs are probed again in the background and get exactly what Reload
from Disk gives them (shared reload_ops): a file that decodes now loses the
flag, one that still doesn't gets the current reason and a fresh stamp. The
result is one journaled system batch, not an undo step; develop settings and
history are kept. Unreadable files are left for next time, photos changed
while probing are skipped, and a result for a library closed meanwhile is
dropped. A toast says how many raws now develop from their raw data.

- engine: cmd/recheck.rs (stale / probe / apply) and
  library.recheckPreviewOnly {all?} -> {checked, updated, cleared}; refused
  without file access, where every probe would clear every flag
- desktop: recheck::tick once per library generation, through tasks::spawn
  ("Checking raw files" in the activity stack)
- the preview-only notice (Edit, Info) gains a Reload from Disk button
- strings translated in all nine catalogs; docs/mcp.md and the parity row

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:19:43 +01:00
Val EnfysandClaude Opus 5.5 5773533e23 LR-IMP-FORMATS: preview-only photos record the app version that decided it (#662)
Catalog format v9 adds Photo.preview_checked and Op::SetContent.preview_checked:
the app version (CARGO_PKG_VERSION) whose decoder set preview_only, empty when
it isn't set. Import and content_op (Reload, Relink) stamp it; undo swaps it
back. Catalogs from before v9 read an empty stamp, so their preview-only photos
count as stale (Photo::preview_only_stale) and will be rechecked once.

The version guard test now also accepts a format version that only adds fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:19:17 +01:00
Andrew FrahnandClaude Opus 5.5 e5cd0c7ada Merge upstream/main into i18n-911-dialogs-toasts
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:12:25 +11:00
Andrew FrahnandClaude Opus 5.5 1181b89048 Merge upstream/main into pr/cr3-variants
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:12:20 +11:00
Andrew FrahnandClaude Opus 5.5 64c780e72f Merge upstream/main into pr/l10n-fill
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:05:23 +11:00
Alex ChambersandClaude Opus 5.5 8feda7a997 LR-IMP-FORMATS: the TIFF reader's next-IFD pointer no longer overflows on huge BigTIFF entry counts
Pre-existing on main. The reader computed the position of a directory's next-IFD pointer before it
checked that every declared entry had been read: a saturating multiplication of the declared count,
then an unchecked addition. A 44-byte BigTIFF declaring u64::MAX entries therefore panicked with
overflow checks on. The position is now computed only for a directory read whole, with checked
arithmetic.

32-bit note: the same guard compared the counts after narrowing the declared one to usize, so on
wasm32 a BigTIFF declaring 4,294,967,297 entries but holding one passed for a whole directory and its
next pointer was followed. The counts now compare as 64-bit numbers (reads_every_entry). On wasm32 the
old comparison followed all six malformed test chains and the new one follows none; 64-bit results
are unchanged for every directory whose count is read whole.

Regression tests (crates/tiff/src/reader.rs):
a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow, which panicked before;
a_bigtiff_count_past_u32_max_never_follows_the_next_pointer (the whole chain, on every target); and
only_a_directory_read_whole_has_a_next_pointer (the comparison itself, on every target).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:40:42 +02:00
Alex ChambersandCodex c03eb8222f LR-IMP-CAMERA-COVERAGE: verify 30 public camera models
Recheck all 54 files in the 41-model shortlist on main 25ef65c8.
Pin 40 accepted files by SHA-256 and full decoded sensor FNV-1a,
and record their camera-image correlation. Exclude eleven candidate
models after checking identity, camera module, provenance and references.

Update generated coverage and tracking documents to 265 verified models.
Keep Nikon coding limits and the own-matrix DNG rendering issue explicit.

Co-Authored-By: Codex <[email protected]>
2026-10-14 08:21:44 +02:00
Alex ChambersandClaude Opus 5.5 ac677a2624 LR-EDIT-OPTICS-PROFILE: decode Sony shading, CA and distortion tables with settings and provenance; apply nothing new
Sony ARWs record shading, lateral chromatic aberration and distortion as a camera setting plus a radial
table in the raw image IFD (0x7031-0x7037), with copies in the encrypted SR2SubIFD (0x797c-0x7982).
They are now decoded for every body that records them and carried through RawInfo and RawImage as
`lens_metadata.sony` (types in crates/raw/src/lens_metadata.rs, decoder in
crates/raw/src/vendor/arw_lens.rs):

- Raw-IFD fields are the ones the TIFF reader parsed, which is what the image itself uses. A scan of
  the directory adds diagnostics only: repeated tags, and why an entry the reader skipped was dropped
  (values outside the file, an unknown type, past the reader's limits). SR2SubIFD fields are read
  from their first entry. Only the exact layouts are accepted: SHORT[1] or non-negative SSHORT[1]
  settings, SSHORT[17] headed by 16 for shading and distortion, SSHORT[33] headed by 32 for CA.
  Nothing is allocated from a count in the file.
- The raw IFD's copy wins. The SR2SubIFD copy fills in only for a tag the raw IFD lacks, never for a
  malformed one, and the two copies' agreement is recorded.
- Settings keep their number and their documented meaning, Unknown(value) otherwise (never Off).
- Framing evidence (stored frame, Sony and default crops, reference rectangle, coding, 3:2) and a
  per-component status. Only Applied components become opcodes.

Applied corrections are unchanged: the ILCE-7RM4A distortion conversion receives exactly the table it
received before, and no other body or component gains a correction. The ILCE-7CR distortion misses its
camera JPEG at 24 mm, and shading and CA semantics are not validated on any body; see
docs/sony-lens-corrections.md.

Locating the SR2SubIFD, now done for every ARW that has one, selects the SR2Private offset, length and
key with the TIFF reader's own rules, which lightcraft-tiff now exposes and builds its reader on
(directory_entries, entry_span, ParseOptions::budget_for, decode_value). Only each field's first value
is decoded, so the directory's other entries cost no memory sized by the file: a 16 MiB file whose
SR2Private holds eight fields aliasing it peaks at 37 kB. DirectoryEntries keeps its layout private,
and its positions() iterator uses checked arithmetic.

Tests: synthetic decoder tests; a differential test of the SR2Private selection against the TIFF
reader's in both byte orders (0 to 8,193 filler entries, unreadable and unusual first occurrences of
each field, a used-up value budget); a dhat peak-heap test; public-corpus checks against the values
ExifTool prints, with pinned and anchored SHA-256s (LIGHTCRAFT_CORPUS_REQUIRED=1 makes absent files
fail); and an opt-in local inventory check (crates/raw/tests/sony_lens_inventory.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:19:21 +02:00
Brandon Lim 619c21288e Merge branch 'main' into fix/1493-unstack-on-delete 2026-10-14 14:18:04 +08:00
Brandon Thomas a4b53b28cc Merge pull request #1682 from storytold/claude/integrate-batch-35
Integration batch 35: 61 reviewed community PRs (never-crash, raw decode, imports, UI fixes)
2026-10-14 02:04:11 -04:00
Brandon ThomasandClaude Opus 5.5 180d400cdd Batch 35: one header line per tracker doc, one 2026-10-13 section in What's New, parity summary refreshed, rustfmt after merges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:55 -04:00
Brandon Thomas 5fe332c7b4 Merge PR #1570 (updated head)
# Conflicts:
#	README.md
#	crates/raw/src/vendor/nef_lens.rs
#	docs/gaps.md
#	docs/nikon-lens-corrections.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-14 01:54:50 -04:00
Brandon Thomas 195fb9698b Merge PR #1427 (updated head)
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 01:53:54 -04:00
Brandon ThomasandClaude Opus 5.5 a5989d6c0d Batch 35: #1132's cancel test takes the four-part fake import #1412 left
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:41:33 -04:00
Brandon Thomas 48b9a5e21f Merge PR #1132
# Conflicts:
#	crates/engine/src/lightroom_job.rs
2026-10-14 01:40:32 -04:00
Brandon Thomas 418f6255be Merge PR #1543
# Conflicts:
#	crates/engine/src/export.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 01:40:03 -04:00
Brandon Thomas 9085eb4b23 Merge PR #1653
# Conflicts:
#	docs/parity-checklist.md
2026-10-14 01:39:50 -04:00
Brandon Thomas 694ce5b42b Merge PR #1537
# Conflicts:
#	docs/gaps.md
2026-10-14 01:39:50 -04:00
Brandon Thomas aa0f9a5666 Merge PR #1536
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 01:39:49 -04:00
Brandon Thomas 5e1213c963 Merge PR #1598 2026-10-14 01:39:06 -04:00
Brandon Thomas ea7f799607 Merge PR #1597 2026-10-14 01:39:05 -04:00
Brandon Thomas 71a43d55ce Merge PR #1661 2026-10-14 01:39:04 -04:00
Brandon Thomas 89b6a34595 Merge PR #1652 2026-10-14 01:39:04 -04:00
Brandon Thomas cc7179c560 Merge PR #1667
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:39:03 -04:00
Brandon Thomas 4fe0466942 Merge PR #1666
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:39:02 -04:00
Brandon Thomas e783f705dc Merge PR #1483 2026-10-14 01:39:00 -04:00
Brandon Thomas 35c4290d14 Merge PR #1477 2026-10-14 01:38:59 -04:00
Brandon Thomas 0a68a21e03 Merge PR #1459
# Conflicts:
#	crates/raw/tests/dng.rs
2026-10-14 01:38:59 -04:00
Brandon Thomas 61d96fc037 Merge PR #1655 2026-10-14 01:38:58 -04:00
Brandon Thomas 93bd81a480 Merge PR #1557
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:57 -04:00
Brandon Thomas 1dd41b1872 Merge PR #1550
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:56 -04:00
Brandon Thomas 4d0e11651a Merge PR #1546
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 01:38:55 -04:00
Brandon Thomas c8f1ecd4d3 Merge PR #1544 2026-10-14 01:38:54 -04:00
Brandon Thomas deb7831083 Merge PR #1542
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:53 -04:00
Brandon Thomas b54a2e9793 Merge PR #1541 2026-10-14 01:38:52 -04:00
Brandon Thomas 992e67188b Merge PR #1539
# Conflicts:
#	docs/whats-new.md
2026-10-14 01:38:51 -04:00
Brandon Thomas b18ce72b61 Merge PR #1535
# Conflicts:
#	crates/raw/src/vendor/pef.rs
2026-10-14 01:38:49 -04:00
Brandon Thomas 1c65549d10 Merge PR #1533
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-14 01:38:48 -04:00
Brandon ThomasandClaude Opus 5.5 9b7d2e6ae2 Batch 35: #1443's face-tile test passes the preview edge #1410 added
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:34:10 -04:00
Brandon Thomas 07f73c44da Merge PR #1443
# Conflicts:
#	crates/engine/src/files.rs
#	docs/raw-preview-reads.md
#	docs/whats-new.md
2026-10-14 01:29:30 -04:00
Brandon Thomas b4d5b47617 Merge PR #1453 (rebased onto main) 2026-10-14 01:27:32 -04:00
skybluu bcfda31678 Merge current main into DNG identity fix 2026-10-13 21:34:33 -05:00
skybluu 83a89ff271 Merge current main into source build guidance 2026-10-13 21:31:07 -05:00
sanyamsingh001andCodex 3489c45cf9 LR-MASK-OBJECTS: integrate model folder retry with pinned main baseline
Preserve model setup and retry behavior alongside the upstream folder
opening callback. Keep the Linux opener asynchronous.

Co-authored-by: Codex <[email protected]>
2026-10-14 02:19:43 +00:00
skybluu f12cf65ab7 LR-BUILD: document command-local low-memory release overrides (#1196) 2026-10-13 21:19:28 -05:00
skybluu ce607d5a49 LR-LIB-CONVERT: avoid duplicated DNG camera make (#840) 2026-10-13 21:13:51 -05:00
Marco Arnulfo 61b4007c2e LRC-LIB-PEOPLE: a double click on an unnamed face opens its photo in the loupe (part of #1664) 2026-10-14 03:37:37 +02:00
Andrew FrahnandClaude Opus 5.5 0242a75b5a Faces: run the networks' convolutions on the GPU (wgpu) when it agrees with the CPU and is faster
An accelerator hook in the face interpreter takes Conv nodes; lightcraft-gpu implements it with a WGSL
implicit-GEMM convolution (any stride, padding, grouping; split-K for small maps; a direct kernel for depthwise
layers), each model's weights resident on a device of its own. After loading, detection and recognition check
the GPU in the background (agreement within 1e-3 and faster than the CPU) and otherwise stay on the CPU, which
remains the reference; the first GPU error drops it. Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 12:22:50 +11:00
Marco Arnulfo aaf7f2cc47 Loupe: the filter pill sits as far from the left edge as from the top (fixes #1645) 2026-10-14 02:39:16 +02:00
eliets2 ddc347cc55 Reject non-finite denoise inputs and derived pixels 2026-10-14 02:11:50 +03:00
Marco Arnulfo 528a464740 Search: clearing the filters empties the search field too (fixes #1648) 2026-10-14 01:04:42 +02:00
Fabio Lussi d5b79bf9a1 Merge remote-tracking branch 'origin/main' into lc-956
# 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/panels/dialogs.rs
#	crates/ui-egui/src/tests_dialog_fit.rs
#	docs/control-protocol.md
#	docs/parity-checklist.md
2026-10-13 23:53:00 +02:00
eliets2 3ad5eadab2 LR-REM-HEAL: checkpoint zero-feather spot edge correction
Treat a zero-width feather transition as a hard edge so exact-radius pixels remain unchanged, including at zero opacity. Add actual image regressions for all spot modes, image corners and two clone output sizes.

Validation: both new regressions failed on the original implementation and pass here. Formatting, workspace Clippy, HEIF and a generated-photo app smoke test pass. The unfiltered workspace gate remains blocked by the existing all-masks overlay test tracked by #1599; later parity, layers, assets and WASM stages are pending. This is a source checkpoint, not a green release candidate.

Contributor: @eliets2
2026-10-14 00:51:56 +03:00
SarimSyed e347d5fd8a LC-SAFETY: protect originals when exporting presets 2026-10-13 17:26:08 -04:00
Andrew FrahnandClaude Opus 5.5 363a1e2a67 Merge upstream/main into pr/file-writes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 08:08:53 +11:00
Andrew FrahnandClaude Opus 5.5 dffd1951a0 LR-BEHAV-ACCESS: merge upstream/main into canvas tools by keyboard
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 08:08:15 +11:00
Andrew FrahnandClaude Opus 5.5 9b9676ca85 Merge upstream/main into pr/more-verified-bodies
Coverage regenerated with docs/raw/coverage.py: 354 verified, 824 unverified,
230 preview, 38 unsupported; ~86% / ~58% use-weighted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 08:06:27 +11:00
Andrew FrahnandClaude Opus 5.5 83d388002d Merge upstream/main into pr/pen-pressure
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:58:20 +11:00
Andrew FrahnandClaude Opus 5.5 506566d436 Merge pr/raw-erf (with upstream/main) into pr/raw-mef
Docs recounted with docs/raw/coverage.py: 241 verified, 35 unsupported. The MCP
headless extension list now comes from engine::import::EXTENSIONS upstream, which
already carries mef.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:56:09 +11:00
Andrew FrahnandClaude Opus 5.5 a3071fe0f3 Merge upstream/main into pr/raw-erf
Docs conflicts resolved on upstream's newer state (Leica Q3, Hasselblad Lunar);
ERF counts recomputed with docs/raw/coverage.py: 240 verified, 229 preview.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:55:22 +11:00
Andrew FrahnandClaude Opus 5.5 37deb70da9 Merge upstream/main into pr/more-verified-bodies-2
Conflicts in ROADMAP.md, docs/gaps.md, docs/parity-checklist.md and docs/raw-parity.md resolved by
taking upstream's text and re-applying this PR's rows; coverage re-measured with docs/raw/coverage.py
(334 verified, 844 unverified, 50% of photographers on a verified body).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:53:45 +11:00
Andrew FrahnandClaude Opus 5.5 853f3ddafe Merge upstream/main into pr/more-verified-bodies-3
Coverage regenerated with docs/raw/coverage.py (258 verified, 920 unverified);
doc conflicts resolved on upstream's text with this PR's numbers re-applied.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:53:29 +11:00
Andrew FrahnandClaude Opus 5.5 80a8acaa30 Merge upstream/main into pr/zoom-detail
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:52:45 +11:00
Andrew FrahnandClaude Opus 5.5 ee5ae8fa1d Merge upstream/main into i18n-911-dialogs-toasts
Resolves the export dialog HDR AVIF colour-space label against upstream's PQ/HLG curve choice (now tr_format!) and three-way merges the locale catalogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:48:41 +11:00
Andrew FrahnandClaude Opus 5.5 0cb32e384e Merge upstream/main into pr/more-verified-bodies-4
Coverage regenerated with docs/raw/coverage.py: 254 verified (18%), 932 unverified, 224 preview.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:43:46 +11:00
Andrew FrahnandClaude Opus 5.5 2d42bdab03 Merge upstream/main into pr/l10n-fill; es: Filmstrip is «tira de película» (review)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:41:30 +11:00
Fabio LussiandClaude Opus 5.5 3d14f50d78 Drop the root AGENTS.md docs rule; keep the PR to docs/user/
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:38:07 +02:00
Andrew FrahnandClaude Opus 5.5 f2f6f1386f Merge upstream/main into pr/cr3-variants
Keeps upstream's ColorData 66 handling (white level from the data); recounts
coverage (259 verified, 919 unverified, 232 preview); drops the duplicate
raw-parity rows; notes high-QP R6 Mark II C-RAW and unverified R7 C-RAW.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 07:27:04 +11:00
good-news-everyoneandClaude Opus 5.5 ab64e9a563 KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it
As over Lightroom's values, a press on the number next to a slider's name
that moves sideways now changes the value as dragging the thumb would: the
pointer's travel over the track's width, a tenth as far with Shift, on the
control's steps and range, as one undo step. A press that moves less than
3 points sideways is still a click that opens the value for typing
(KEY-SLIDER-TYPE), and the pointer over the value is the horizontal-resize
cursor. The tooltip says both ("Click to type a value, drag to change it"),
translated in every catalog.

Tests: scrub distance and direction, Shift, clamping, steps, one interaction
per scrub, no field or draft left behind, a jittery click still types, a
disabled slider doesn't scrub. The readout test that drags away from the
value now drags straight down, so it still checks that a cancelled press
keeps neither a value nor the keyboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:55:17 +03:00
Navid Zaman Khan aa4e3ce822 fix(ui): propagate find-missing errors and timeouts 2026-10-13 22:45:47 +06:00
Michael Anderson 1ee751ebdf LR-EDIT-OPTICS-PROFILE: Apply Nikon's in-file distortion correction for the Z 6II, Z 8 and Z f
Read the polynomial, zoom and optical center offset from NikonNEFInfo.
2026-10-13 11:10:46 -05:00
Brandon Thomas d71e7d11c1 Merge PR #1429
# Conflicts:
#	crates/ui-egui/src/panels/left.rs
2026-10-13 11:52:08 -04:00
Brandon Thomas 93f8ee7c0d Merge PR #1427
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-13 11:52:07 -04:00
Brandon Thomas ce3fad5a77 Merge PR #1567
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-13 11:52:01 -04:00
Brandon Thomas f478b000de Merge PR #1580 2026-10-13 11:51:51 -04:00
Brandon Thomas cd59698798 Merge PR #1583
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:24 -04:00
Brandon Thomas e067581ff3 Merge PR #1581 2026-10-13 11:49:23 -04:00
Brandon Thomas 3f9b3b20cb Merge PR #1579
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:22 -04:00
Brandon Thomas 3b848a2093 Merge PR #1578 2026-10-13 11:49:21 -04:00
Brandon Thomas 13c32ef1bb Merge PR #1577 2026-10-13 11:49:21 -04:00
Brandon Thomas 05dc7374d8 Merge PR #1576 2026-10-13 11:49:20 -04:00
Brandon Thomas 9f8a17277e Merge PR #1574 2026-10-13 11:49:20 -04:00
Brandon Thomas 0a2b2afb2e Merge PR #1573 2026-10-13 11:49:19 -04:00
Brandon Thomas 2ddc147f60 Merge PR #1572 2026-10-13 11:49:18 -04:00
Brandon Thomas 7942df9dd4 Merge PR #1571 2026-10-13 11:49:18 -04:00
Brandon Thomas 642662d90f Merge PR #1570
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 11:49:17 -04:00
Brandon Thomas f62be0925c Merge PR #1569 2026-10-13 11:49:16 -04:00
Brandon Thomas 2294329b0c Merge PR #1565
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 11:49:15 -04:00
Brandon Thomas 7627b2b2b8 Merge PR #1563
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:49:14 -04:00
Brandon Thomas 5ece09aa6c Merge PR #1562 2026-10-13 11:49:13 -04:00
Brandon Thomas 2fd0530beb Merge PR #1561
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:13 -04:00
Brandon Thomas 8bcbcd918b Merge PR #1560
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:12 -04:00
Brandon Thomas 6b00656df8 Merge PR #1559
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:11 -04:00
Brandon Thomas 2341132b35 Merge PR #1558
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:11 -04:00
Brandon Thomas c6b2686ba0 Merge PR #1410
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:49:05 -04:00
Brandon Thomas 02bdad38d2 Merge PR #1439
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:48:58 -04:00
Brandon Thomas 6d124d701b Merge PR #1451 2026-10-13 11:48:38 -04:00
Brandon Thomas d823d155b2 Merge PR #1421 2026-10-13 11:48:37 -04:00
Brandon Thomas f0493fa11b Merge PR #1419 2026-10-13 11:48:36 -04:00
Brandon Thomas 836bc449f0 Merge PR #1454 2026-10-13 11:48:35 -04:00
Brandon Thomas ac1eaf1d68 Merge PR #1452 2026-10-13 11:48:34 -04:00
Brandon Thomas 03b6c8a95d Merge PR #1423 2026-10-13 11:48:34 -04:00
Brandon Thomas cd5f16d5ac Merge PR #1445 2026-10-13 11:48:33 -04:00
Brandon Thomas 840733f760 Merge PR #1434 2026-10-13 11:48:32 -04:00
Brandon Thomas 7505017ea3 Merge PR #1432 2026-10-13 11:48:32 -04:00
Brandon Thomas 481272c1a3 Merge PR #1456 2026-10-13 11:48:31 -04:00
Michael Anderson 853a92e2de LR-EDIT-OPTICS-PROFILE: Apply Nikon's in-file distortion correction for the Z 6II and Z f
Read the polynomial, zoom and optical center offset from NikonNEFInfo.
2026-10-13 10:47:57 -05:00
Michael Anderson 271bc41842 Never crash: Repair damaged catalog snapshots on open
Fix ids, stacks, album folders and counters that don't agree, and stop album trees at 64 deep.
2026-10-13 10:46:47 -05:00
Michael Anderson 4a07254e06 Never crash: Read the edge pixel for bilinear samples outside the picture
Infinite coordinates from a zero-size crop overflowed the sampler.
2026-10-13 10:46:42 -05:00
Michael Anderson f753778f45 Never crash: Fix blur overflow and hangs on sliver crops
Cap the sigma at twice the picture's long side and square the box width in i64.
2026-10-13 10:46:37 -05:00
Michael Anderson 4c088c3284 Never crash: Give the loupe a finite aspect for a zero-size crop
Count each side as at least one pixel.
2026-10-13 10:46:33 -05:00
Michael Anderson b43b7169aa Never crash: Limit how much a preset zip can unpack
Cap entries at 256 MB and the archive at 1 GB, and check the offsets.
2026-10-13 10:46:28 -05:00
Michael Anderson 307843b2ad Never crash: Fix defringe radius overflow for amounts past the sliders
Treat the amount as the slider's end and keep the radius within the picture.
2026-10-13 10:46:23 -05:00
Michael Anderson e1860067a6 Never crash: Render demo scenes at most at their own size
A damaged width asked for a buffer size that overflowed.
2026-10-13 10:46:18 -05:00
Michael Anderson 911d7df321 Never crash: Fix panic dragging a tone curve point between close neighbours
Keep the point midway when there's no room, and keep curve points sorted.
2026-10-13 10:46:13 -05:00
Michael Anderson 57487875b5 Never crash: Cap the spot radius so huge spot sizes render (#1519)
Sidecar spot sizes aren't limited to the slider range. Count at most two long edges.
2026-10-13 10:45:50 -05:00
Michael Anderson f157654628 Never crash: Fix overflow on a BigTIFF IFD with a huge entry count (#1516)
Only read the next IFD offset when the whole table was read, with checked math.
2026-10-13 10:45:45 -05:00
Michael Anderson 27d1e9b04d Never crash: Cap enlarged exports at 512 MP and check memory first
A huge enlargement failed to allocate and took the app down.
2026-10-13 10:45:39 -05:00
Michael Anderson 337f02b4dc Never crash: Fix export size panic for very long strips
Apply the 1 px and 65535 px bounds in turn instead of clamping between bounds that can cross.
2026-10-13 10:45:34 -05:00
Michael Anderson 3624a50423 Never crash: Refuse raw data with two samples per pixel (#1511)
The demosaic read past each pixel. DNG decode and validation refuse it now.
2026-10-13 10:45:29 -05:00
Michael Anderson cef6f2d4b8 Never crash: Fix demosaic panic on a NaN black level
Read non-finite DNG black levels as 0 and map non-finite normalized samples to black.
2026-10-13 10:45:18 -05:00
Michael Anderson d5cba8b3dc xtask parity: Fail when docs/gaps.md lists a gap more than once
Also remove the duplicate rows for gaps 10, 22 and 23.
2026-10-13 10:45:11 -05:00
PiMinusMax d60fd328db Before/After button: Show Original (\) does not light it (fixes #1497)
The bottom bar's Before/After (Y) button was lit for every mode but Off,
so holding or toggling \ (Show Original) lit it too, and a click then
opened the side-by-side layout: one lit button for two different views.
It now stands only for the Before/After layouts.
2026-10-13 17:45:07 +02:00
Michael Anderson 65dbba9ecd LRC-LIB-CATALOG-IMPORT: Open catalogs whose tables are all still in the WAL
Accept schema format 0 in the main header and check page 1 again after the WAL overlay.
2026-10-13 10:45:06 -05:00
Michael Anderson b62b51378e Never crash: Fix i32 overflow in the NEF and PEF Huffman predictors (#1515)
Use checked adds and return a corrupt data error instead of panicking.
2026-10-13 10:45:00 -05:00
Michael Anderson e9d36451e6 Never crash: Fix overflow in mask.move with a huge delta
Saturate index + delta so any delta just moves the mask to the top or bottom.
2026-10-13 10:44:46 -05:00
PiMinusMax d64a1ecc93 LR-PROF-CAMERACOLOR: bundled Sony ILCA-99M2 camera profile
Fitted with `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of
150, seven shooting days, Standard creative style, DRO Auto) and their
in-camera JPEGs; aggregate colour statistics only.

On 30 withheld photos (profile fitted to the other 120) mean dE76 against
the camera JPEG goes 5.11 -> 4.87; 25 of 30 improve, the worst
regression is 0.24. All 150 photos stay accepted. The public raw.pixls.us
ILCA-99M2 file, not used for fitting, stays accepted (5.57 -> 5.73).

A corpus test checks that this public sample's fit keeps exactly the
built-in profile's matrix and table; the ILCE-7CR test now shares its
body. LOOK_VERSION 5 -> 6, as the profile changes the fit of this model.
2026-10-13 17:33:23 +02:00
Jason Baldwin bc9d8a21b5 Merge slideshow-templates into slideshow-export (origin/main: batches 32–33) 2026-10-13 17:03:26 +02:00
Jason Baldwin d747c9e0f6 Merge slideshow-titles into slideshow-templates (origin/main: batches 32–33) 2026-10-13 17:01:24 +02:00
Jason Baldwin f30e4af838 Merge slideshow-overlays into slideshow-titles (origin/main: batches 32–33) 2026-10-13 16:59:34 +02:00
Jason Baldwin ba9262a4a0 Merge slideshow-composer into slideshow-overlays (origin/main: batches 32–33) 2026-10-13 16:57:38 +02:00
Jason Baldwin a021ad2c56 Merge slideshow-playback into slideshow-composer (origin/main: batches 32–33) 2026-10-13 16:55:11 +02:00
Nathaniel LedfordandClaude Opus 5.5 2783e5de9b PERF-IMPORT: a scan, an import and a sidecar write no longer make a table of the whole library first
Setting up Synchronize Folder, an import review or an import made a table
of every photo in the library on the session's thread before the first
file was looked at, and naming one photo's sidecar worked out who owns
every shared sidecar name in the library. In a library of 3.5 million
photos (`scan_setup_bench`, release, a folder of 500): 5.8 s before a
Synchronize scan, 2.6 s before an import review, 1.7 s before an import,
2.4 s for each sidecar written.

- A scan keeps the library as it is (photos are shared, not copied) and
  looks up the files it finds (`library_files`) and the contents it probes
  (`library_hashes`) on its own thread.
- An import looks the library up with its first files
  (`ImportJob::look_up_library`), from the library and the options as they
  were when it was set up.
- The owners of shared sidecar names are worked out for the photos asked
  about (`StemOwners::among`): all five callers name only those photos.
- Synchronize Folder names its photos' sidecars in the scan, after it has
  seen that the folder is there.

Now 0.9 s, 0.05 s, 0.05 s and under 1 s. What is found, and how sidecars
are named, is unchanged: the tests hold the owners against the table of
the whole library as it was made before, and the key made without
allocating against the key, for every string of up to six of the
characters that decide it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:52:56 -04:00
Jason Baldwin 09ea6a3f77 Merge origin/main into slideshow-playback (painted toast from batch 33, kept at the top during a slideshow; both Slot variants; tracker rows) 2026-10-13 16:52:50 +02:00
MarekSpodBiedry 3946688f49 Reject RAW TIFFs with missing or undecodable image chunks (#1513) 2026-10-13 16:38:39 +02:00
MarekSpodBiedry d77d1a4650 MCP: do not resend a request whose reply was lost, report an unknown outcome (#1526) 2026-10-13 16:37:38 +02:00
MarekSpodBiedry 82da463f56 Refuse Open/New Library while an import is running (#1509) 2026-10-13 16:37:30 +02:00
MarekSpodBiedry 935fabeb18 Preview disk cache: ignore symlinked shard directories (#1504) 2026-10-13 16:36:39 +02:00
MarekSpodBiedry f865aa28c3 Reject a LeakyRelu alpha that is not a single float instead of reading it as 0 (#1530) 2026-10-13 16:36:37 +02:00
MarekSpodBiedry 8e26ba6c94 Check the SAM weight shape against the header before reading the tensor (#1529) 2026-10-13 16:36:24 +02:00
MarekSpodBiedry 51ce34d78c Reserve the name with an exclusive create when hard links are unavailable (#1503) 2026-10-13 16:36:09 +02:00
MarekSpodBiedry 4a91a1c218 Fetch: refuse a redirect from https to http (#1531) 2026-10-13 16:35:28 +02:00
MarekSpodBiedry 7d29dfac58 Recheck file contents before deleting a moved original (#1500) 2026-10-13 16:35:21 +02:00
MarekSpodBiedry df914dca41 Reject PEF streams that end before the pixels do instead of decoding zero padding (#1514) 2026-10-13 16:35:19 +02:00
notdezziandZCode 54321e6175 Packaging: confirm a successful uninstall with the completion dialog (fixes #892)
The MSI never included WiX's ExitDialog: the Publish events point at it, but only
a DialogRef pulls the UI extension's fragment (with its on-exit=success show) into
the package, so every successful run ended silently. Reference it, and note that
the shared completion page is the uninstall confirmation; packaging-lint now fails
when the reference is lost again.

🤖 Generated with [ZCode](https://zcode.ai)

Co-Authored-By: ZCode <[email protected]>
2026-10-13 16:15:46 +02:00
LosminekandClaude Opus 5.5 95cde76917 Merge upstream/main into cs-ui
Conflict in panels/dialogs.rs: keep upstream's PQ/HLG HDR curve choice
and route both labels through i18n::tr. Czech for the new strings:
8 catalog entries (import, missing files, GPU notice, mask overlays),
3 format strings (other editor, identical copies) and the HLG label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:12:20 +02:00
Jason Baldwin fdad5a4ad4 LR-VIEW-FOCUSPOINT: read focus points for photos already in the library, header-only in the background (#1480)
Photos imported before focus points were read got one only through Reload from Disk, which
hashes whole files on the UI thread. Now:

- lightcraft_meta::focus::read_header reads only what the decoder looks at: IFD0 (Make,
  Model), the Exif IFD, the maker note's IFD and the focus tags' values (a JPEG: its segments
  up to the Exif one), walked round by round and decoded with the whole-file code from a
  buffer of the file's length holding just those ranges. Bounded rounds, bytes (2 MB) and
  size (1 GB). One 32 KB read per α1 / A7S III ARW; on 109 real files (83 with a point)
  it gives the whole-file read's point for every one.
- A background job (crates/engine/src/focus_scan.rs) reads photos with no point from a
  supported body (by the stored camera; nothing else is opened), ARW or JPEG, one at a
  time in path order, with a "Reading focus points" activity row (Cancel::Flag). The UI's
  per-frame focus.pump gives it the face scan's pace (paused while dragging or typing) and
  starts it once per library opening. Commits every 200 photos or 3 s as system ops (no
  undo step, no sidecar write), only meta.focus, only on photos that still have none at the
  same path. "Looked, none" goes to focus-scanned.bin beside the library, headed by the
  decoder's tag so a decoder knowing more bodies starts afresh; unreadable files are retried
  at the next opening.
- photo.readFocusPoints {ids?, wait?} (Photo > Read Focus Points) runs it on demand.

Measured cold on a 5400 rpm USB drive: 200 α1 / A7S III ARWs in 1.9 s (~100 photos/s,
32 KB each), so 58,796 raws take about 10 minutes instead of re-reading 2.25 TB.
2026-10-13 16:06:06 +02:00
Navid Zaman Khan d5826e2386 fix(develop): reject malformed settings groups 2026-10-13 19:47:05 +06:00
squareSphere29andCursor 6e20f82360 LR-LIB-STACK: deleting a stacked photo drops it from the stack so i/n badges update (fixes #1493)
Soft-delete and remove-folder now unstack in the same undo step as Recently Deleted, so leftover members show 1/2 not 3/3. Restore-from-trash does not restack.

Co-authored-by: Cursor <[email protected]>
2026-10-13 21:02:42 +08:00
eliets2 c38bf96ef9 Docs: Select Only Active Photo in the shortcut list and the parity checklist
Add the Cmd+Shift+D row to the library shortcut table with a short note on
what it does and where it can be rebound, add a checklist row for it with
status partial (covered by headless key tests; the native macOS menu path
is not verified), and update the checklist's Last updated line, revision
history and the summary counts that this one row changes.
2026-10-13 15:42:56 +03:00
Fabio LussiandClaude Opus 5.5 e665aff1b3 docs: re-verify against 0.7.1
- All pages checked against the v0.7.1 tag and its release assets;
  verified: bumped to 0.7.1.
- Install: 32-bit Windows (x86) installer and portable build; pre-0.6
  upgrades keep their install folder.
- Import: Recently Deleted photos are restored by default; progress shows
  in the activity stack.
- Edit: HDR button next to B&W.
- Export: JPEG XL Effort and Progressive, lossy JPEG XL colour space
  note, Add to Apple Photos on macOS.
- Lightroom import: activity stack progress and stopping with its X.
- Raw troubleshooting: Adobe RGB fix now released, monochrome picture
  style tone, EOS R5 Mark II C-RAW decodes, Leica DNG and dark scenes
  still open; drop "import again" (duplicates are skipped).
- docs/user/CLAUDE.md is a regular file instead of a symlink.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:34:54 +02:00
dev-null-undefinedandClaude 14e0d12813 LR-IMP-PREVIEW-READ: stand-ins and raw thumbnails decode the smallest embedded JPEG that covers them
`embedded_preview_srgb` (the loupe stand-in and the grid thumbnail of an unedited raw) decoded the
largest embedded JPEG whatever the size asked for: a 512 px thumbnail of a Sony A7R VI ARW decoded
its 9984 x 6656 JPEG (5.6 MB) although the file holds a 1616 x 1080 one (0.3 MB).

`lightcraft_raw::embedded_preview_covering(bytes, min_edge)` returns the smallest of a TIFF-based
raw's JPEGs whose long edge is at least `min_edge` and that is a copy of the largest one: aspect
ratio within 1 % (not a letterboxed thumbnail of another shape) and the same number of components
(not the grey semantic masks of an iPhone ProRAW DNG); else the largest, as `embedded_preview`.
The colour-space handling stays in `decode_raw_preview` (a `covering` argument); an unsupported
raw's editing source and the camera-look fit keep the largest JPEG, so a source never depends on
its size level.

On 120 raws of every make in the corpus at 256 / 512 / 1600 px: wherever the largest JPEG is still
chosen the output is identical byte for byte; where a smaller one is (Sony, Nikon, Panasonic,
Adobe DNG) orientation matches, the size is equal or 1-2 px off, mean difference <= 4.7 / 255.

- Rebased onto #1020: its plan route (`preview_window` -> `planned_preview`) now takes the box size and picks
  with `embedded_preview_covering` too, so a TIFF raw read through the sparse plan gets the whole file's JPEG.

Co-Authored-By: Claude <[email protected]>
2026-10-13 14:33:52 +02:00
nevicia2010-hub f86459db58 LR-MASK: fix range handle drags and color sampling settings 2026-10-13 14:29:16 +02:00
eliets2 fb47e4a185 Tests for Select Only Active Photo
Cover the chord in the photo grid, the square grid and the detail view
(selection narrowed, active photo kept, last edit undo step untouched), a
focused text field keeping the key, rebinding and unbinding in the keymap,
and the command refusing to run without an active photo.
2026-10-13 15:17:01 +03:00
eliets2 6f5e1f876e Edit: add Select Only Active Photo (Cmd+Shift+D)
A new library command narrows the selection to the active photo and keeps
that photo active. It is bound to Cmd+Shift+D (Ctrl+Shift+D on Windows and
Linux), listed in the Edit menu and the keyboard shortcut editor, and
disabled while there is no active photo. The binding does not collide with
any existing binding or alias.
2026-10-13 15:17:01 +03:00
eliets2 939d422ff6 Docs: grid navigation keys, Cmd+O and Shift+H
Describe the new keys in the library shortcuts page, update the rows they
touch in the parity checklist and the UI parity page, and stop listing F6
and F7 as missing in the small-shortcuts gap now that they are bound.
2026-10-13 15:16:51 +03:00
eliets2 68e9d61aa6 Tests for the grid navigation keys, Cmd+O and Shift+H
Cover Home/End and Shift+Left/Right in both grids (ends of the view, the
loupe, the user's own binding winning), Cmd+O asking for a folder once,
Shift+H toggling the HDR view, and the new keys staying out of the way
while a text field has the keyboard, while the full-screen preview covers
the grid, and while a dialog is open.
2026-10-13 15:16:51 +03:00
eliets2 4dfd78a8a8 Library grids: Home/End and Shift+Left/Right selection keys; Cmd+O and Shift+H
In Photo Grid and Square Grid, Home and End make the first or last photo of
the view the active, selected one, and Shift+Left / Shift+Right add the
previous or next photo of the view to the selection. These keys wait while
the full-screen preview covers the grid, in every other view, while a text
field or a dialog has the keyboard, and when the user gave the key to a
command. They are listed under Fixed keys in the keyboard shortcuts dialog.

Open Library now has Cmd+O and Visualize HDR has Shift+H (Cmd+Shift+H stays
the Histogram).
2026-10-13 15:16:46 +03:00
Matthew James SivakandClaude Opus 5.5 eeb2ca2f6a LR-EDIT-SECTION-SOLO: register section.calibration, refuse unknown sections
Calibration's header runs section.calibration, now a registered command (no menu
item); section.<id> refuses an id the Edit panel hasn't got instead of closing
the real sections for a phantom one. "Solo Mode" sits with the other So… keys
in the catalogs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:13:48 +02:00
Matthew James SivakandClaude Opus 5.5 12015179ea LR-EDIT-SECTION-SOLO: section.<id> {solo?} and header clicks run the command
Option-click goes through section.<id> {solo: true}, so agents can open just one
section; view.soloSections and section.* refuse flags that are not true / false
(bool_param); toggle_section reuses solo_section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:13:48 +02:00
Matthew James SivakandClaude Opus 5.5 d8f62a29b2 LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections
Window > Edit Sections > Solo Mode (view.soloSections {on?}) and a right-click on
any section header turn it on: opening one section closes the others. Option-click
on a header opens just that section once. Kept with the UI state (singlePanel).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:13:48 +02:00
Matthew James SivakandClaude Opus 5.5 4d240cf616 LR-EDIT-EFFECTS-DEHAZE: raster::morph square min / max filters, Image::transpose
Split out of #1286 (Dehaze V3) so it can be reviewed and land on its own.
`min_filter` / `max_filter` are separable van Herk / Gil-Werman filters:
three comparisons per pixel per pass whatever the radius, edges clamped,
NaNs skipped like f32::min, a short buffer or empty plane returned
unchanged. `Image::transpose` (row-parallel) does the column pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:10:21 +02:00
HossamvsandCursor 8d6070e224 LR-LIB-PEOPLE: rename a person across the library (#1444)
People cards and the person page had no way to rename every face of one person as one undo step.

Co-authored-by: Cursor <[email protected]>
2026-10-13 15:00:24 +03:00
dev-null-undefinedandClaude 8c64c467f5 LR-EDIT-OPTICS-PROFILE: Sony lateral CA planes off by default (the demosaic already hides most of it)
Verified on 54 ILCE-7RM6 photos, the CA planes made the render worse: our
demosaic already hides about 70 % of the lens CA (the render shows 0.29x
the raw-CFA CA) while the post-demosaic warp moves R and B by 0.84x the
table, so the corner R/G and B/G CA went from rms 0.032 / max 0.125 px
(off) to 0.052 / 0.219 px (on) at 1440 px; only the 16-35 mm improved.

warp_opcode takes a lateral_ca flag; lens_opcodes passes false, so a Sony
ARW gets the one-plane distortion warp. The CA conversion stays live and
tested (FF and APS-C examples, both lossless corpus files' tables). The
follow-up is to warp the CFA colour planes before demosaic. The doc,
tracker (docs/parity-checklist.md), docs/gaps.md, docs/raw-parity.md and
whats-new say distortion applied, CA parsed and off
by default, vignetting not applied; the one-plane default costs 24 -> 45 ms
in the sample stage at 1920 px (three planes: 112 ms).

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:19:44 +02:00
Jason Baldwin ac84cd31ee LR-VIEW-FOCUSPOINT: show the camera's focus point (Sony) and zoom to it (#1480)
Read the autofocus point from Sony maker notes (FocusLocation 0x2027, FocusFrameSize
0x2037, FocusMode 0x201b, AFAreaModeSetting 0x201c, AFTracking 0x2021 and the area mode
used from the enciphered 0x9402 block), for the ILCE-1 and ILCE-7SM3, whose layout was
checked against ExifTool's output on 333 real ARWs. Manual focus, centre-only Multi
reports and unchecked bodies give no point. The box is stored on the upright photo as
Meta.focus (omitted when unknown; no catalog format change), filled at import and by
Reload from Disk.

View > Show Focus Point (Alt+F) draws it in Detail, Compare and Survey through rotation
and crop; View > Zoom to Focus Point (on by default) makes Toggle Zoom centre on it; the
Info panel shows "AF-C · Wide · Animal Eye Tracking". photo.inspect returns meta.focus.
2026-10-13 13:14:24 +02:00
neronek22 bba2fe0bda Merge pull request #1 from witkowski01/polish-fixes
i18n(pl): plural forms, the messages main gained, and paintable menu arrows
2026-10-13 13:09:45 +02:00
dev-null-undefinedandClaude 33c362a7e4 LR-EDIT-OPTICS-PROFILE: Sony lens corrections verified on the ILCE-7RM6; docs and tracker
docs/sony-ilce7rm6-lens-corrections.md records the three Sony tables, the
measured conventions with their residuals, the conversion to
WarpRectilinear and its fit gate, the model allowlist and how to extend
it, the measurement method, the verification and the limits.

Verified locally on 54 ILCE-7RM6 photos with four lenses. The corrected
render lines up with the camera JPEG within 0.679 px at 1440 px at the
corners and edge midpoints of the 33 measurable photos shot with
in-camera correction on (up to 17.3 px uncorrected). The 13 controls show
the model's own shift. Every fit passes the 0.25 px gate (margin 0.075).
Lateral CA stays within 0.3 px, but applied after demosaic it raises the
render's corner CA (rms 0.032 -> 0.052 px); the doc and tracker say so.

Its "Two conventions" section sets these conventions next to the
ILCE-7RM4A's (vendor::arw_lens, docs/sony-lens-corrections.md, #498):
on the 34 measurable ILCE-7RM6 photos that formula gives 1.429 / 0.319 px
(max / rms) against 0.596 / 0.143, failing only on the 24-70 GM II and
fitting the 100 mm and 200-600 better; no single unit fits every lens
under either, and what would settle it (Sony On/Off exports of a wide
zoom, more lenses). docs/sony-lens-corrections.md gains a pointer to it.

Tracker rows LR-EDIT-OPTICS-PROFILE (one note for both bodies) and
LR-IMP-CAMERA-COVERAGE in docs/parity-checklist.md, gap 8 in
docs/gaps.md, the lens-profile section of docs/raw-parity.md (lens score
unchanged), whats-new (lossless ARWs now; Compressed / Compressed HQ once
the ARW 6.0 decoder, #625, lands) and the colour-fit follow-up note are
updated.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:09:40 +02:00
dev-null-undefinedandClaude d982a073c9 LR-EDIT-OPTICS-PROFILE: Sony embedded corrections on by default for ARW
An ILCE-7RM6 ARW with Sony's distortion table now imports with Enable
Lens Corrections on: Photo::camera_defaults already turns the correction
on for any file whose probe yields embedded lens data, and the ARW
decoder emits it since the previous commit. No new default rule is
needed, so RW2 and DNG keep theirs (on when the file carries a
correction) and stored settings are never rewritten.

Tests pin it: a synthetic ARW imports with the correction on (another
body's tables are not read: off), RW2 / DNG defaults are unchanged, and
an ARW catalogued without lens data or switched off by the user keeps
its settings across a library reopen. The Optics panel's hint drops
"(DNG lens opcodes only)" in every locale.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:09:40 +02:00
dev-null-undefinedandClaude b677fbac0a LR-EDIT-OPTICS-PROFILE: Sony ARW lateral CA correction from the embedded table
vendor::arw_lens_rm6::warp_opcode (was distortion_opcode) turns the CA table
(0x7035: red half, blue half, on the distortion knots) into the R and B
planes of the WarpRectilinear: a colour's source radius is
g (1 + c(g) / 3.36e6) at the green source radius g. The unit is provisional
(borderline measurement, about +-30 % scene bias). Each colour plane goes
through the same fit gate as G, and a table with a value beyond 2e-3 relative
(about 12 px at the full-frame corner; the 54 photos' tables stay below
5.1e-4, i.e. 1724) is refused; without a CA table, or when it is refused, the
warp keeps its single G plane, and CA alone is never applied. fit_radial_warp
now rounds its gate up to the next sample, capped at 1 (a NaN gates every
sample), so the band between the last sample inside the crop and the crop
corner is checked too (RW2 passes 1.0: unchanged).

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:09:40 +02:00
dev-null-undefinedandClaude 85cc4273be LR-EDIT-OPTICS-PROFILE: Sony ARW distortion correction from the embedded table
New vendor::arw_lens_rm6 reads the raw IFD's lens tables (0x7037 distortion,
0x7032 vignetting, 0x7035 lateral CA, their flags) and turns the distortion
table into a one-plane WarpRectilinear in OpcodeList3, with the conventions
measured on 54 ILCE-7RM6 photos: knots at i/15 of the full-frame crop half
diagonal, S(r) = r (1 + v(r)/15000), frame-filling zoom, centred on the
default crop. Only the ILCE-7RM6 is enabled; vignetting is read, not applied.
The radial least-squares fit is split out as vendor::fit_radial_warp (RW2
results unchanged); its error gate can be limited to the radii the crop
shows, so a padded active area (lossless files) doesn't refuse a fit over
pixels that are never rendered.

It sits next to main's vendor::arw_lens (#498), which holds the ILCE-7RM4A
conventions measured against Sony's exports (unit 16384, table indexed at the
output radius); the two are selected by camera model (disjoint allowlists).
arw::decode chains this module's opcodes after arw_lens::distortion, with the
same active area and the same crop (the DNG-style default crop when the file
has one, which on the ILCE-7RM6 equals Sony's crop tags), and like it only
for CFA data (not the downsized linear-RGB lossless sizes, where the knot
spacing in full-frame pixels would not hold).

The corpus test checks the conversion on the two lossless RawDB files (CC0).
The APS-C one is pinned in xtask RAW_SAMPLES with its sha256; the full-frame
one is not (at most two pins per PR, and #625 pins two Compressed ARW 6.0
files), so its case runs only where the file is present.

RENDER_CACHE_VERSION 62 -> 63.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:09:40 +02:00
dev-null-undefinedandClaude 6c5b4d5701 UI: find gsettings on PATH for Appearance Mode ▸ Auto (NixOS has no /usr/bin/gsettings)
Without the XDG settings portal, the start-up fallback asks gsettings for
the GNOME colour scheme, but only looked in /usr/bin, /bin and
/usr/local/bin. NixOS keeps it in /run/current-system/sw/bin or the
per-user profile, so Auto never followed the desktop there. It now tries
the absolute PATH entries first, then those three; relative entries are
still skipped, so nothing runs from the working folder.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:07:36 +02:00
dev-null-undefinedandClaude dfe87240b0 Nix: evaluate the flake in CI so a new git dependency can't break nix build / nix develop again
Since UI.9 (9bfabc98) Cargo.lock has a git source, craft-ui from
storytold/craft-libs. importCargoLock needs a hash for every git
dependency, so evaluation stopped with "No hash was found while vendoring
the git dependency craft-ui-0.1.0" and both `nix build` and `nix develop`
failed on NixOS for two days (#1314 fixed it with
cargoLock.allowBuiltinFetchGit).

The flake is not built in CI, so nothing noticed: the new Nix workflow
runs `nix flake check --no-build --all-systems` whenever flake.nix,
flake.lock, nix/ or Cargo.lock change.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:07:36 +02:00
dev-null-undefinedandClaude e7cd00ef56 M5.6: a zoomed view with Dehaze keeps its airlight proxy (each pan and slider step resampled the whole original)
A windowed render dehazes with the whole frame's airlight, estimated on a 384 px proxy of the frame
(`local::frame_airlight`). Every window asked for that proxy again, so every pan of a 1:1 view and every
slider step while zoomed sampled the whole original down to it on the CPU: ~200 ms of CPU on a 64 MP raw.

The proxy (taken before white balance) is now kept for the last 4 (source fingerprint, frame, size); white
balance, the dark channel and the percentile still run on it every time. Same pixels: the GPU window of
`render_bench` is byte-identical.

`optics::fingerprint` indexed the first pixel of an empty image; it now hashes only the dimensions then.

render_bench ONLY=window GPU_ONLY=1 on a 64 MP Sony ILCE-7RM6 raw (RTX 4070 Ti SUPER, 4-CPU quota), CPU time:
pan 208-212 -> 9-11 ms, exposure change 200-204 -> 4 ms, NR change 211-217 -> 13 ms.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:07:34 +02:00
dev-null-undefinedandClaude 17a68863f6 LR-EDIT-OPTICS-PROFILE: share the radial warp fit between RW2 and Sony
Move the forward-map inversion, least-squares fit and solve4 out of the RW2
decoder into vendor::warp_from_forward_map; RW2 calls it in DNG-normalised units.

Co-Authored-By: Claude <[email protected]>
2026-10-13 13:07:20 +02:00
Atmik Shetty 21861c2f14 LR-EDIT-DETAIL-NR: noise reduction works in the original's pixels, so previews match the full-size render scaled down (fixes #960)
Grid thumbnails looked much more denoised than the loupe. `nr_params` sized
the radii from the output's scale relative to the source buffer it was given,
not the original: a 256-px cell drawn from the 512-px thumbnail level used
half the full-size radius on pixels that each average ~23 sensor pixels, and
below 0.4x (0.35x for colour) the radius stopped shrinking at all. The
guided filter's eps (the variance treated as noise) also stayed at its
full-size value although downscaling divides the noise variance by 1/scale²,
so texture in small previews was flattened as noise.

- `SourceInfo::native_long` (filled by the engine from the catalog's photo
  size, kept when the decoder supplies its own facts, carried to web
  workers) and `Plan::detail_scale` = output pixels per original pixel
  (from `px_per_long`, so a crop no longer changes it).
- `nr_params(s, scale)`: radii x scale with no floor, eps x scale². Below
  1 px the smallest blur the box Gaussian makes is blended in by the ratio
  of variances (`blur::box_sigma`); a fade under 1% skips the filter.
  At scale 1 the parameters are unchanged: 1:1 views and full-size exports
  render as before. The GPU takes the same parameters.
- RENDER_CACHE_VERSION 63: cached thumbnails are redrawn.

Measured on a 1536x1024 noisy synthetic scene (tests_nr_scale.rs), error
of the preview against the full-size render scaled down, as a fraction of
leaving NR off, luminance NR 50 at preview scales 0.5 / 0.33 / 0.25 /
0.167 / 0.1 / 0.05: before 0.53 / 0.70 / 1.04 / 2.26 / 3.87 / 6.07, after
0.28 / 0.37 / 0.66 / 0.90 / 0.99 / 1.00. A 256-px render from the 512-px
level now matches one from the original (mean 0.07 LSB vs 0.71 before).
2026-10-13 16:14:19 +05:30
Atmik Shetty 25e832b404 KEYC-COMPARE: = and - make the grid thumbnails larger and smaller
View ▸ Increase / Decrease Grid Thumbnail Size (view.thumbSizeUp / view.thumbSizeDown)
step the bottom-bar slider by 30 pt within its 90–480 range in Photo Grid, Square
Grid and People, as Lightroom Classic's Library Grid keys do; + also enlarges.
Detail, Compare, Survey and Reference ignore the keys, text fields keep them, and
⌘= / ⌘− still zoom the photo. The slider's range is now shared constants.
2026-10-13 16:11:47 +05:30
Atmik Shetty db69e288c8 KEYC-RATING: backtick cycles the flag; Cmd+Up / Cmd+Down raise / lower it
photo.cycleFlag steps each selected photo's own flag unflagged -> pick ->
reject -> unflagged; photo.increaseFlag / photo.decreaseFlag move it up or
down reject < unflagged < pick without wrapping. One batch undo step, Compare
and Survey act on the active photo, Auto Advance follows, text fields keep
the key. Shown in Photo > Set Flag and Help > Keyboard Shortcuts.
2026-10-13 16:11:45 +05:30
Markus FriedelandClaude Opus 5.5 8907f5f56f LR-VIEW-PHOTOGRID: a mouse-wheel notch scrolls a third of the grid or filmstrip (#1481)
One notch scrolls the photo grid by a third of its visible height and the
filmstrip by a third of its width, as in Lightroom Classic, instead of the fixed
100 points a side panel scrolls. It jumps at once instead of gliding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 12:38:21 +02:00
dev-null-undefinedandClaude Opus 5.5 6467d54325 Perf: camera-look colour step 1.5-1.9x faster on full-size raws, same pixels
The hue/saturation table of a fitted camera look (ILCE-7RM4A, APS-C ILCE-7RM6, ...) ran
HsvTable::apply pixel by pixel: branches on the data (largest channel, hue sector),
saturating float-to-usize casts and bounds-checked table reads, ~550 instructions and 1.2
branch misses per pixel. CameraColourModel::apply now runs it 16 pixels at a time
(HueSat::apply_lanes -> HsvTable::apply_lanes, structure of arrays): per pixel the same IEEE
operations in the same order, with the branches turned into selects and the table indices
computed as exact f32 integers, so the compiler vectorises everything but the table reads,
powf and the rare rem_euclid, which run lane by lane. DNG profile tables and the other colour
models keep the per-pixel path.

Measured on a Core Ultra 9 185H P-core (busy machine, min of N):
- HsvTable alone, 72x5x5 fitted-look-shaped table, random colours, one thread:
  243 -> 106 cycles (57 -> 26 ns) per pixel.
- Colour stage of a full-size load, one thread: 60 MP ILCE-7RM4A 3.80 -> 1.99 s,
  26 MP APS-C ILCE-7RM6 HQ 1.88 -> 0.98 s; 4 CPUs: 1.02 -> 0.67 s, 0.56 -> 0.30 s.

Exactness: new tests compare the lanes with the one-by-one path bit for bit (to_bits) on tens
of thousands of colours (every combination of zero, -0, tiny, negative, around 1, huge, NaN and
+-inf components; grey; hues at the 0/360 degree wrap; near-black noise in the restore's blend)
for fitted, value-scaled, 2D, many-turn and degenerate tables, 16 and 5 lanes, and on an image
whose rows aren't a whole number of lanes. Stage fingerprints (decode, AHD, 2560 px and
full-size loads) of 7 Sony raws are identical to origin/main.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 12:03:21 +02:00
radialmonster 2dad18d2ec LR-RAW-NEF: stretch non-square pixels (D1X/D1H) to square
The D1X/D1H raw SubIFD is 4028x1324 with XResolution 600 / YResolution 300:
the pixel is twice as tall as it is wide. Nothing read that, so the
picture came out about 3:1. Add a pixel aspect to RawImage/RawInfo, read
it from the raw IFD resolution ratio (NEF) and from DefaultScale (DNG),
accept only 1.5..4, and resample the rows after demosaic. developed_size()
reports the stretched size, binning is off for non-square pixels, and the
catalog and lens-profile sizing use developed_size().
2026-10-13 06:01:40 -04:00
draco892 bd0ca28bb0 build: fix host architecture detection in Windows PowerShell 5.1
Use Windows environment variables instead of RuntimeInformation
to avoid a null value during the final CLI smoke test.

Account for emulated shells using PROCESSOR_ARCHITEW6432.
2026-10-13 12:00:04 +02:00
Claude 951d52788a LR-MERGE-HDR: pixels next to clipped ones don't tint HDR merges
Measured against the darkest frame on real ARW brackets, each frame is linear to 1 % up to the clip far from
clipped pixels, but within 2 px of them the +2 EV frame's green reads 3–5 % low: demosaicing interpolates it from
clipped photosites. That frame carries the most weight (k = 4) and those pixels were unclipped themselves, so they
kept it, and bright windows took a magenta tinge.

A frame's weight now goes by its brightest channel within 1 px (3 × 3; wider margins changed nothing more). On two
real interior brackets (ILCE-7M3, 0 / −2 / +2 EV), bright pixels whose green against red and blue is over 10 % below
the darkest frame's, in camera RGB: dining room 0.32 % → 0.08 %, kitchen 2.86 % → 0.28 %. Rendered at −3 EV with
main's colour tags (#1123), window pixels with G < 0.85·min(R, B) go 50 → 3 and 16 → 10 (the darkest frame alone:
2 and 205); most of the visible magenta those brackets showed before came from the missing colour tags. Test: a
bracket whose +2 EV frame has 5 % less green next to its clipped pixels merges without the deficit (fails before:
−1.96 %).

Docs: docs/merge.md, the LR-MERGE-HDR row, docs/whats-new.md (also for the frame-order fix).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 09:51:11 +00:00
dev-null-undefinedandClaude Opus 5.5 5bf455c456 LRC-LIB-CATALOG-IMPORT: an ARW6 is described from the whole file, not a partial read
header_plan skipped an ARW6's strip but its first 64 KB and then said Ready, while the ARW6
header probe (Mode::Header) validates every tile: its picture header, stream tables and the
first lines of its residual stream, which lie in the skipped bytes. The probe of the partly
read buffer then failed ("ARW6 first-half row count outside 2..=7") where the whole file
succeeds: sparse_corpus failed on arw6-sony-ilce7rm6-apsc-land-c.arw, the first ARW6 file it
reads (it stops at the first failure). Like NEF, ORF and PEF, whose header probes read
samples, an ARW6 now gets HeaderPlan::Whole from the header plan. The preview plan is
unchanged: the embedded preview reads no tile, and the 13 ARW previews in the corpus are
still read from 21 % of their bytes.

Only the test reads the header plan today (the app's partial reads use preview_plan), so no
import changes. New test: a synthetic two-tile ARW6 whose second tile lies past a first one
of bulk size (Ready before, Whole now). sparse_corpus passes on the 8 ILCE-7RM6 ARW6 files.

cargo test --workspace: 3205 passed, 1 failed, visualize::tests::
all_masks_overlay_shows_every_visible_mask_at_once, which fails on main b3d392b4 too
(PR #1456 addresses it); fmt, clippy, parity, layers, assets and wasm pass.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:45:40 +02:00
eliets2 0cd7513a8d LR-MERGE-HDR: an HDR merge of raws keeps the as-shot white balance of its inputs (#1144)
The HDR merge has the same cause as the panorama: its result took the
reference frame's colour tags, and a raw whose white balance is only in the
vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral in them.

merge_hdr now takes the median multipliers of all its inputs (the rule of
consensus_multipliers: the mean of the middle two for an even count; missing,
non-finite and non-positive values do not vote) and sets the result's
AsShotNeutral to their reciprocal, as stitch does. With no multipliers (DNG
sources) the reference frame's tags are kept, as before. HdrResult carries the
multipliers so that the brackets of an HDR panorama vote as frames of their own
when the merged brackets are stitched (the engine passes them on).

In the app and the CLI the neutral from starting_colour_tags (#965) is already
there for frames balanced alike, so those renders do not change.

Tests: an HDR merge of raw-style frames gets the median AsShotNeutral (odd and
even counts, missing and unusable multipliers ignored) and the written DNG says
it; without multipliers the reference frame's colour is unchanged.
2026-10-13 12:23:36 +03:00
eliets2 25314aaacf LR-MERGE-PANO: a panorama of raws keeps the as-shot white balance of its frames (#1144)
A raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...)
has no AsShotNeutral of its own. Loading a frame dropped the multipliers and
stitch copied the first frame's colour tags, so a panorama of frames that reach
the stitcher without a neutral wrote a DNG with neither AsShotNeutral nor
AsShotWhiteXY, and a viewer had no white to start from (a strong colour cast
until White Balance > Auto).

Frames now carry the multipliers (Frame::wb_multipliers, filled from the decoded
raw; load_frame_with is split so a decoded raw can be tested without a file).
stitch takes the median R/G and B/G of the multipliers of the frames that are in
the panorama (the mean of the middle two for an even count; frames without
multipliers, or with non-finite or non-positive ones, do not vote) and sets the
result's AsShotNeutral to their reciprocal, green = 1. The source's
AsShotWhiteXY is dropped with it: a reader takes it before the neutral and the
DNG spec makes the two exclusive. With no multipliers anywhere (DNG sources) the
first frame's tags are kept, as before.

In the app and the CLI a raw without colour matrices already gets a neutral from
starting_colour_tags (#965). For frames balanced alike the new neutral renders
the same (checked in the engine crate); it differs when the camera balanced the
frames differently: the panorama follows the median instead of the first frame.

Tests: the frame carries the multipliers; the consensus for odd and even counts,
with missing and unusable values; stitching raw-style frames gives the median
AsShotNeutral, ignores frames outside the panorama and leaves the colour as it
was without multipliers; the written DNG, read back with the raw reader, has the
reciprocal AsShotNeutral and the white balance the source's multipliers give.
2026-10-13 12:23:36 +03:00
Claude 5cb57ef4ea LR-PROF-CAMERACOLOR: Canon EOS R7 / R6 Mark II calibrate measurements
Held-out and library-wide calibrate runs on real CR3s (calibrate's CR3 /
CR2 input is #1073): profiles change little (R7 2.52 -> 2.57, R6 Mark II
6.12 -> 6.09 mean dE), so none is bundled; notes the CRX adaptive QP
172/175 files that stay preview-only. The catalog test pins that an EOS
R6 Mark II (Exif "Canon EOS R6m2") edits in Kelvin through its measured
matrices and an EOS R7 stays relative.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CzWyUn77ZWLe3q3qMiQbVt
2026-10-13 09:10:47 +00:00
cookingwithsimonandClaude Opus 5.5 d9cea1404a LR-IMP-PROFILES: presets apply the imported profile they name (#1001)
A Lightroom preset's crs:Look (name, group, UUID, amount; the Look table
in .lrtemplate files) is kept as profile.look and resolved against the
user's imported profiles by name, the group breaking ties. Lightroom's six
basic profiles map to ours; its own creative/camera profiles and profiles
not imported yet are reported per preset. References are resolved again
after profile.import / profile.deleteImported, so profiles and presets can
be imported in either order.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FxP2xEy623xzWGqCWXVwtb
2026-10-13 09:08:53 +00:00
draco892 eec4746a56 build: automatically include craft-fonts in desktop packages
Detect ../craft-fonts or use CRAFT_FONTS_DIR on macOS, Linux and Windows.
Download the pinned release revision when the checkout is missing
and require Chinese and Japanese fonts during the build.

Document font setup and preserve skip-build behavior.
2026-10-13 11:01:40 +02:00
PiMinusMaxandClaude Opus 5.5 0d13a7d5d7 M5.6: a full-size raw source is no longer copied once more after the colour model
`fit` to the source's own size copies the whole picture (`resize` returns a clone). The raw path
called it unconditionally, so every full-size decode (an export, and its denoised twin) made a
second float RGB buffer: 288 MB and 160 ms at 24 MP, ~390 MB and 225 ms at 33 MP. The JPEG and
preview paths in the same file already fit only when the picture is larger than `max_edge`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:27:54 +02:00
witkowski01andClaude Opus 5.5 488edec6c8 i18n(pl): plural forms, the messages main gained, and paintable menu arrows
Fixes for the Polish catalogs of #1100, on top of a merge of main:

- English plural suffixes ("s"/"") and the "it"/"them" words in 51 format
  messages are now consumed with `{:.0}`, as docs/localization.md asks;
  Polish showed "Zaimportowano 5 zdjęćs" and "utracisz they are". The
  counts read "Zaimportowane zdjęcia: 5", which is right for every number
  (Polish has three plural forms; ru and uk use the same pattern).
- The 19 format messages main gained since the translation was written;
  without them the build fails.
- 118 plain messages the other catalogs already translate.
- "▸" and "▾" in translations become "→" and "⌄", as the other catalogs do
  (translated_catalogs_do_not_use_missing_menu_triangles).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:27:09 +02:00
PiMinusMaxandClaude Opus 5.5 b36b0d14b7 KEY-SLIDER-NUDGE: scrolling sideways over a slider adjusts it
A trackpad swipe, a tilting wheel or Shift+wheel over a Develop slider nudges it by the
wheel/arrow-key steps, without selecting it first (the right panel only scrolls vertically).
Only a mostly sideways movement counts, so scrolling the panel leaves the sliders alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:10:24 +02:00
witkowski01 5d981c2c68 Merge main into the Polish translation 2026-10-13 10:04:35 +02:00
radialmonster 4f51f051e7 LR-RAW-NEF: only take the NRW block's white balance where the as-shot kelvin is mirrored (P7000); P7100 gets none 2026-10-13 04:03:59 -04:00
dev-null-undefinedandClaude bc1f138ee3 Perf: ARW6 decode another 20-34 % faster, same samples
- The two halves of each stream are independent bit streams: they now decode in parallel
  (`decode_stream`) instead of one after the other inside each of a tile's 13 streams.
- The vertical inverse 5/3 runs its rows in parallel. The horizontal pass lifts each row in place
  (`lift_row`, scratch space reused per thread), rows in parallel, instead of allocating two vectors
  per row and copying the result back; a test checks it against the vertical pass, row by row.

Best of 3 on 4 CPUs, previous and new decoder alternating: ILCE-7RM6 FF HQ 0.60 → 0.48 s
(process CPU 1.14 → 0.87 s), FF Compressed 0.62 → 0.48 s, APS-C HQ 0.34 → 0.23 s, APS-C
Compressed 0.31 → 0.24 s (docs/arw6-compression.md → Timings). The decoded mosaic is identical
(hash of every sample) on the 8 ARW6 files of the RawDB ILCE-7RM6 set and 3 private A7R VI photos.

Co-Authored-By: Claude <[email protected]>
2026-10-13 10:00:33 +02:00
dev-null-undefinedandClaude Opus 5.5 79ab54e167 Perf: ARW6 decode 20-33 % faster, same samples
- The vertical inverse 5/3 lifts whole rows at once (`inverse_lift` with samples `w` values wide)
  instead of gathering each column through a strided iterator and scattering it back: the same
  integer arithmetic, sequential memory. The horizontal pass is the same function with w = 1.
- `0^n 1` codes are counted with one `leading_zeros` over the next MAX_DPT + 1 bits instead of bit by
  bit (same bits consumed, same errors); each line reserves its values up front.
- Colour reconstruction reads plane rows as slices and runs over rows in parallel (it was one
  thread per tile: 4 threads on a full-frame file whatever the machine).

Best of 5 on 4 CPUs, previous and new decoder alternating: ILCE-7RM6 FF HQ 0.56 → 0.46 s,
FF Compressed 0.54 → 0.38 s, APS-C HQ 0.34 → 0.23 s, APS-C Compressed 0.35 → 0.24 s
(docs/arw6-compression.md → Timings). The decoded mosaic is identical (hash of every sample) on
the 8 ARW6 corpus files and 2 private full-frame photos (ISO 100, ISO 12800); the pinned corpus
sums still match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:00:33 +02:00
dev-null-undefinedandClaude Opus 5.5 f407813569 Perf: AHD demosaic 1.7× faster and 0.8 GB lighter on a 64 MP raw, same pixels
Three changes, each leaving the output bit for bit as it was:
- Each step runs only where the tile's output depends on it (choice ±0, homogeneity ±1, Lab ±2,
  greens ±3, mosaic ±5 inside the 6 px margin), with direct neighbour indices instead of an
  index clamped on every read; the clamped values at the buffer edge never reached the output.
- A candidate pixel is built in registers before its Lab conversion: writing its channels
  through an index and reading the pixel back stalled on store forwarding (the hottest
  instructions of the profile).
- Tiles are written into the output one band at a time, instead of all 64 MP of tiles being
  collected and then copied (797 MB held twice). Tiles inside the image copy their mosaic rows
  instead of reflecting and taking a modulo per sample.

Sony ILCE-7RM6 9984 × 6656 Compressed HQ raw, demosaic only, best of 5 on 4 cores:
1.85 s → 1.05 s wall, 6.3 s → 4.0 s CPU. Verified identical (f32 bits) on 7 real raws
(ILCE-7RM6 FF/APS-C, ARW6 HQ/C and lossless, ILCE-7RM4A) and, against the previous code, on
random mosaics of all four Bayer layouts at 1 × 1 … 300 × 7, 257 × 130 (tile edges, reflection).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:00:33 +02:00
dev-null-undefinedandClaude Opus 5.5 5f2f013c8e Perf: a full-size raw decode no longer copies its 64 MP image to "fit" it
`load_bytes_now` ran every development through `resample::fit`, which returns a clone when the
image already fits. At full size (1:1 zoom, export) that cloned the whole picture: 797 MB and
~0.3 s for a 9984 × 6656 Sony ILCE-7RM6 raw. It now fits only when the image is larger, as the
other preview paths in files.rs already do. Output unchanged (same pixels, by construction).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:00:12 +02:00
radialmonster 967e57fa50 LR-RAW-NEF: read the as-shot white balance from the NRW data block when the maker note has no WB tag (P7000, P7100) 2026-10-13 03:58:09 -04:00
radialmonster fe9e6e3433 LR-RAW-NEF: size the NRW word packing by the row, not by the padded strip (P7000 stripes) 2026-10-13 03:58:09 -04:00
eliets2 be592cf2b1 LR-IMP-FORMATS: CI-enforce the unsupported CRX variants, listed in docs/cr3.md
docs/cr3.md gains a variant-status table: every supported and rejected CRX
coding variant, the function that gates it and the exact Unsupported text.

crates/raw/tests/cr3_variants.rs enforces the rejections (19 tests). Each
rejected variant is rebuilt as a small synthetic CR3 (container, CMP1
descriptor, CRX stream) and the test asserts the exact string, and that
probe_info (the import path) and decode return the same error. Control
fixtures decode through the same container, including both edges of the
accepted uniform quantization range (4..=44) and adaptive QP range
(131..=168), so a rejection can only come from the documented gate. The
table itself is prose and is not checked against the tests.

The doc said only QP values 131-167 were accepted; the decoder also accepts
168 (quantization value 44, step 102), so that sentence now says so.

Tests and docs only; no decoder change.
2026-10-13 10:56:29 +03:00
Atmik Shetty cb8b797cd4 KEY-SLIDER-RESET: a slider's name and thumb say a double-click resets it (fixes #1436)
A double-click on a slider's name or thumb already reset it, but nothing said so: only the value had a
tooltip. Resting the pointer on the name or on the thumb now shows "Exposure — double-click to reset
it", the wording (and translations) the curve channels already use. The rest of the track, which is
clicked and dragged, gets no tooltip, and the value keeps "Click to type a value".

The first click of a double-click on the thumb moved the value to where the pointer landed, a few
points off its centre: one history step, then the reset another. A click on the thumb now leaves the
value alone, so the reset is one undo step, as from the name. A disabled slider's name no longer
resets it.

No per-slider reset button: Lightroom has none, and the double-click it documents is now shown where
it works. Tests: tests_slider_reset.rs (name and thumb reset Exposure and Contrast in one undo step,
the hints, a click on the thumb keeps the value, typing a value still works) and
widgets/tests_slider_reset.rs (a disabled slider's name).
2026-10-13 13:16:08 +05:30
radialmonster e82b5089f7 LR-RAW-DNG: DNGs written by older LightCraft keep their sensor-anchored CFAPattern reading; the writer now marks its spec-anchored files 'LightCraft DNG 2' 2026-10-13 03:44:44 -04:00
eliets2 115b19bdcb LR-IMP-FORMATS: fuzz structurally valid CR3 containers in the robustness tests
crates/raw/tests/robust.rs only mutated DNGs. Add two small valid CR3
fixtures (a 4x4 lossless CRX and a 16x16 three-level C-RAW CRX) and a
property that flips bytes in them or truncates them, then runs the existing
exercise() helper (probe, embedded_preview, decode, develop and the colour
transform) plus probe_info, the header-only description used on import:
errors or clean decodes, never a panic. Unlike random TIFF-like bytes, a
structurally valid file gets past the container walk, so the mutations land
in the CRX marker walker and entropy readers. A fixture test asserts that
both files decode as built, so the mutations hit live decode paths.

Tests only; the existing DNG property keeps its 400 cases and the new one
uses proptest's default (256, PROPTEST_CASES overrides it).
2026-10-13 10:41:11 +03:00
Atmik Shetty 64ccc20853 KEY-HIDEPINS: Tool Overlay modes (Auto / Always / Selected / Never) for spot and mask pins, Shift+H cycles them (fixes #224)
The Remove tool's spot pins (Remove / Content-Aware / Heal / Clone) and the
mask pins now follow a Tool Overlay mode, saved with the UI state:

- Auto (default): shown while the pointer is over the photo (or a pin is
  dragged), hidden while a new spot or brush stroke is painted
- Always: all of them
- Selected: only the selected spot's or mask's
- Never: none; the tool still works and a click paints a new spot

A hidden spot can't be grabbed. Mask pins also need Show Pins
(view.maskPins), which keeps its meaning and reply.

Command view.toolOverlay {mode?: auto|always|selected|never} (no mode steps
to the next), Shift+H (H stays Remove), View > Tool Overlay with the modes
and the cycle item, and a Tool Overlay dropdown in the Remove and Masking
panels. Headless tests cover each mode with the pointer over and away from
the photo, grabbing, the key, the command, the menu, the dropdown, mask pins
with Show Pins, and the ui.json round trip.
2026-10-13 13:10:57 +05:30
eliets2 f4b7175429 LR-IMP-FORMATS: property tests for the CRX decoder through the public decode path
crates/raw/tests/crx_properties.rs drives lightcraft_raw::decode with
synthetic CR3 containers built around CRX streams that the test encodes
itself: reference entropy coders for the MED, Rice, MELCODE and QP-map
layouts, and the exact algebraic inverse of one 5/3 lifting line. The
tile-halo coefficients are free parameters of that inverse, so synthesis
must reconstruct any encoded plane exactly. Tests only; no decoder change.

- Wavelet round trip: random planes decode exactly across odd plane sizes
  and multi-tile halo configurations (eight geometries, arbitrary halo
  values).
- Quantization: every uniform quant value 4..=44 and every adaptive QP value
  131..=168 dequantizes within the 14-bit range. Overshoot is clamped, and
  an in-range plane lands exactly on level * step, so the step sequence
  itself is observable. Quant 3 and 45 and QP 130 and 169 are rejected by
  decode and probe_info alike. Random QP maps, including odd map heights,
  with random per-band base and gain stay in range.
- Lossless samples outside the bit depth are Corrupt errors instead of being
  clamped; in-range planes decode exactly.
- Adversarial entropy payloads planted in well-formed lossless, C-RAW,
  version 0x200 and QP-map streams either error or decode deterministically
  and never panic. A QP Rice prefix longer than the 23-zero escape is
  Corrupt.
- Random IAD1 geometry either decodes with the active area inside the
  sensor and the crop inside the active area, or is rejected.
- Decoding gives identical results across repeated and four-way parallel
  invocations.
- Odd QP-map heights decode: the last QP row has no partner and is used
  alone, and QP values are averaged before the step lookup. Large QP jumps
  take the escape code.

The case budget is proptest's default (256 per property); set
PROPTEST_CASES to change it.
2026-10-13 10:40:45 +03:00
wstockandClaude Fable 5.1 5e9a1b6ec7 Visualize: the all-masks overlay shows the union of the masks, not the last one
`Overlay::AllMasks` painted each mask's plane in turn, and every mask view
rewrites the whole image from its alpha (White on Black writes `255·a`,
Image on Black multiplies by `a`…), so each plane wiped the ones before it
and only the last visible mask showed. The planes are now merged (per-pixel
max) and painted once; the GPU path goes through the same `apply`.

The test added with #919 could not pass: besides the above, its hidden-mask
check expected the plain pixel under White on Black, where everything
outside the masks is black by definition (as the single-mask test asserts).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
2026-10-13 07:26:18 +00:00
wstockandClaude Fable 5.1 e935ad75e2 LR-VIEW-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip select in Compare
In Compare a filmstrip click goes to `compare.set` (#747), so the grid's
modifier clicks never reached `library.select` and multi-select from the
filmstrip was impossible there (#187 follow-up). A ⌘/Ctrl-click now toggles
the photo and a Shift-click extends from the active photo, as in the grid;
the two photos shown stay selected and the active photo is unchanged, so a
rating key still acts on a photo that is on screen. A plain click still
replaces the active side.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
2026-10-13 07:22:42 +00:00
s17sh70x2jfl031agandClaude Opus 5.5 094686a2f6 i18n(pt-br): translate AI Denoise
The AI Denoise panel, model download and settings were still in English
in Brazilian Portuguese; this translates its 98 strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:11:58 -07:00
Marco Arnulfo 0d8e3e3913 LR-IMP-ADD-DIALOG: an import ends showing its first photo, in All Photos when the view doesn't hold it, unless another photo was chosen meanwhile (fixes #1446) 2026-10-13 09:04:10 +02:00
Marco Arnulfo c10ca4e958 LR-IMP-PREVIEW-READ: a raw whose embedded preview can't be read still gets a thumbnail (fixes #1448)
The grid queues no render behind an unedited raw's quick thumbnail, since the embedded JPEG is
meant to be the thumbnail. But whether that JPEG can be read is only known when the quick job
runs, and `quick_thumb_job` had no fallback: when the preview loader returned nothing the job
failed with "no quick preview", the failure was remembered for that key, and the cell stayed
empty until an edit changed it. The quick job now carries the real thumbnail job as its last
resort, as `quick_view_job` already does for the loupe.
2026-10-13 09:01:48 +02:00
Matthew James SivakandClaude Opus 5.5 aeb21ac813 UI: align Local folder rows, paint dock gaps, bottom-align the tool strip
- Local folders in the sidebar are drawn at their tree indent like Albums and
  Folders: the icon lines up with the rows above and the disclosure triangle
  sits in the gutter left of it; children nest by 16 pt (#1097).
- The central panel that hosts the dock is filled with chrome, so the gaps the
  dock leaves between panes no longer show the dark clear colour (#1098).
- The tool strip's bottom group is two buttons and one gap tall, so Info ends at
  the bottom margin as Presets starts at the top one (#1099).

Each fix has a headless regression test that fails without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 09:00:28 +02:00
smposaraandClaude Opus 5.5 26df1c5cdf LR-IMP-CAMERA-COVERAGE: verify Canon EOS RP CR3 pixel for pixel
Four CC0 EOS RP samples from raw.pixls.us (lossless RAW and C-RAW, full
frame and 1.6x crop mode) now have exact full-sensor regressions against
the DNGLab 0.8.0 black-box oracle (Windows binary, checked first against
the documented M50 RAW hash). The decoder already matched; no decoder
change was needed. Crop mode is a single 4032x2656 tile, full frame two
3192-wide tiles.

docs/cr3.md also records that the white level is Canon's specular white,
which at the -1/3-stop ISOs (e.g. 11062 at ISO 5000) is well below the
sensor's saturation point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 08:59:30 +02:00
OneSlipsandClaude Opus 5.5 bd50896ca1 LR-MASK-EFFECTS: merge upstream main into pr/mask-effects
Conflicts: the 9 locale *-formats.json catalogs (both sides appended
messages at the end; kept both) and docs/parity-checklist.md (header
change list, regenerated summary via `cargo xtask parity --write`,
revision history rows from both sides).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:54:08 +10:00
s17sh70x2jfl031ag b73c0599fa i18n(pt-br): fix plurals and 6 inconsistent strings
Many pt-BR format strings dropped the English plural argument with
{:.0}, so nouns never became plural ("Exportada 5 foto"). Also fixes
the gender of "matiz" and two terms that disagreed with the catalog.
2026-10-12 23:41:18 -07:00
Atmik Shetty 63f68f5044 Merge remote-tracking branch 'upstream/main' into feat/print-color
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-13 12:08:59 +05:30
Claude 64f354fca3 LR-MERGE-HDR: merged DNGs declare the white balance their source shows (follow-up to #1123)
#1123 gives HDR and panorama merges of raws without colour matrices the colour they open with, but writes
ColorMatrix1 from the forward matrix, so the as-shot white the DNG declares lands wherever the fitted look and the
generic camera model take it. On real ILCE-7M3 brackets the HDRs opened at As Shot 6113–7307 K / +115 (Duv +0.038)
where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the ARW
at the same Temp/Tint). The render at As Shot was right (0.4–0.8).

The colour matrices now declare the white balance the source shows, decided by files::WbScaleOf (new dng_white):
the camera's measured matrices when they give the source's scale (lightcraft_raw::spectral; the DNG then reads every
white as the source does), else a matrix under which the as-shot neutral is the white the scale shows (Sony's
presets' reading, 6500 K / 0 on the relative scale, the file's own white otherwise). Forward matrix and neutral are
unchanged, so is the render at As Shot.

Measured on the same brackets with this change on a fork build: As Shot 4813 / 5268 / 3932 K, +8 / +8 / +7 as the
ARWs (declared white Duv +0.003); same Temp/Tint within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for
the largest (5268 → 3200 K, 3932 → 6500 K: the ARW adapts its look with Bradford, the DNG rebalances in camera space).

Tests: a merged DNG shows its source's As Shot on all four scales (measured matrices, presets, relative, own); with a
look that tints white the DNG declares the shown white, gives the measured model's multipliers at other whites and
renders at As Shot as the source does. Both fail on main (ILCE-7M3 case: (7342, 115) for (5268, 8)).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 06:37:11 +00:00
Jason Baldwin 6477d031fc LRC-LIB-PEOPLE: face pictures come from the embedded preview and are kept in the thumbnail cache (#1440)
An unedited raw's face tile is cut from its largest embedded preview, read
the planned way (header and JPEG, never the sensor data), when the preview
holds at least 3/4 of the tile's pixels across the face; otherwise, and for
edited photos and other files, it is developed as before. Tiles keep going
to the memory + on-disk thumbnail cache, now at 16 px size steps so the
thumbnail slider reuses them. The planned preview path carries an Adobe RGB
preview colour space and applies it instead of declining to the whole-file
read (Sony alpha 1 previews).
2026-10-13 08:31:23 +02:00
Atmik Shetty 6e5cd82ce7 Merge remote-tracking branch 'upstream/main' into feat/print-color
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-13 12:00:45 +05:30
Brandon Thomas b3d392b499 Merge pull request #1438 from storytold/claude/integrate-batch-32
Integration batch 32-2: 7 reviewed contributor PRs
2026-10-13 02:18:34 -04:00
Brandon ThomasandClaude Opus 5.5 ae9b09d3c2 Batch 32: rustfmt after merges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:07:07 -04:00
Brandon Thomas c94ebae470 Merge PR #1185 2026-10-13 02:06:07 -04:00
Brandon Thomas 4ee6a4888b Merge PR #1126 2026-10-13 02:05:14 -04:00
Arekatla Nishanth ChowdaryandClaude Opus 5.5 1462d96360 Contributors: add my people.toml entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:34:33 +05:30
Arekatla Nishanth ChowdaryandClaude Opus 5.5 edc3661680 Issue #1200: portable mode, a portable.txt beside the executable keeps settings in LightCraftData
As in PhotoCraft: when portable.txt (or LightCraft.portable) sits next to the
executable, config_dir() is <exe dir>/LightCraftData, so settings, logs and
models stay with a portable copy instead of %APPDATA%. LIGHTCRAFT_CONFIG_DIR
still wins and is used without probing beside the executable. A portable
folder that can't be written logs a warning and the platform folder is used.
The check runs once per process.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:34:33 +05:30
Brandon Thomas 68bacba288 Merge PR #1113 2026-10-13 02:04:31 -04:00
Brandon Thomas 8a640dc70b Merge PR #1077 2026-10-13 02:04:06 -04:00
Brandon Thomas 478f20054b Merge PR #1056 2026-10-13 02:02:17 -04:00
Brandon Thomas d8f10a1ef7 Merge PR #919 2026-10-13 02:01:30 -04:00
Brandon Thomas 643bc7f909 Merge PR #850 2026-10-13 01:57:40 -04:00
Claude 146cb7b975 LR-MERGE-HDR: the merge no longer depends on the order the frames come in
The same bracket merged in another order gave a different result: on real ARW brackets, the app's selection
order and the CLI's file order differed on 0.6 % of the pixels (up to 102 levels), while the same order gave
byte-identical files. Alignment seeds were the frames' input positions, and sums, deghost masks and brightness
ties followed them too.

merge_hdr now puts the frames in brightness order first (ties by median, then a content fingerprint, never by
position), runs everything in that order, and returns the per-frame results (EV, alignment, reference index)
in the caller's order. Test: every permutation of a bracket gives the same radiance and deghost overlay bit for
bit, and the same per-frame results (fails before: "the radiance differs").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 05:57:13 +00:00
Claude 8bb084074c Merge storytold/lightcraft main into LR-MERGE-HDR (lens corrections)
Conflicts: crates/engine/src/tests_merge.rs (#1123's test landed where this branch's is; both kept),
docs/parity-checklist.md (the LR-MERGE-HDR row: both notes) and docs/whats-new.md (both entries).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 05:57:08 +00:00
Marco Arnulfo 8155c118cd LR-PREF-DISPLAY: test that the 1:1 window is drawn with the screen's profile (#1365) 2026-10-13 07:46:27 +02:00
BFlat 68c924047d Merge pull request #1431: integration batch 33 (85 reviewed contributor PRs)
Integration batch 33: main green again, 85 reviewed contributor PRs
2026-10-13 12:16:54 +07:00
Claudio 0a565d308a Recently Deleted shows deleted Local (browsed) photos (Fixes #1430)
A photo browsed in a Local folder is flagged deleted by photo.delete, but
Filter::matches_in dropped every browsed photo from views without a folder,
so it vanished from the folder and never reached Recently Deleted: it could
not be restored and sat in the catalog invisibly (a deleted record is also
never forgotten as an untouched Local one).

Deleted browsed photos now match the Recently Deleted filter, so they can be
restored; the library view still excludes browsed photos.
2026-10-12 23:15:38 -06:00
bflatastic 2397927681 Merge main (batch 32a landed)
# Conflicts:
#	apps/lightcraft-cli/src/main.rs
#	crates/codecs/src/lib.rs
#	crates/mcp/src/headless.rs
2026-10-13 12:09:34 +07:00
bflatastic ece9bd83e4 Batch 32: one tracker row per id (rows two PRs both edited), parity summary refreshed 2026-10-13 12:05:26 +07:00
Jason Baldwin 3f9bf67eae Merge slideshow-templates into slideshow-export (toast clear of dialog buttons) 2026-10-13 07:04:13 +02:00
Jason Baldwin ed5c7d38eb Merge slideshow-titles into slideshow-templates (toast clear of dialog buttons) 2026-10-13 07:04:13 +02:00
Jason Baldwin db876a0a3d Merge slideshow-overlays into slideshow-titles (toast clear of dialog buttons) 2026-10-13 07:04:13 +02:00
Jason Baldwin 75c4b5c920 Merge slideshow-composer into slideshow-overlays (toast clear of dialog buttons) 2026-10-13 07:04:13 +02:00
Jason Baldwin 39de3777fa Merge slideshow-playback into slideshow-composer (toast clear of dialog buttons) 2026-10-13 07:04:13 +02:00
Jason Baldwin 21b417fe3c LRC-SS-PLAYBACK: the toast also goes to the top while a dialog is open, clear of its buttons 2026-10-13 07:04:13 +02:00
Jason Baldwin 4ea05a85ae Merge slideshow-templates into slideshow-export (toast clear of the slideshow controls) 2026-10-13 06:55:21 +02:00
Jason Baldwin f36020c3cf Merge origin/main into slideshow-export (batch 32: state.rs keeps the SlideshowExport dialog and New Album's include_selected) 2026-10-13 06:55:21 +02:00
Jason Baldwin a89daef74a Merge slideshow-titles into slideshow-templates (origin/main: batch 32; toast clear of the slideshow controls) 2026-10-13 06:55:03 +02:00
Jason Baldwin fd685e7680 Merge slideshow-overlays into slideshow-titles (origin/main: batch 32; toast clear of the slideshow controls) 2026-10-13 06:55:02 +02:00
Jason Baldwin 38d53ccaee Merge slideshow-composer into slideshow-overlays (origin/main: batch 32; toast clear of the slideshow controls) 2026-10-13 06:55:02 +02:00
Jason Baldwin 2faee15203 Merge slideshow-playback into slideshow-composer (origin/main: batch 32; toast clear of the slideshow controls) 2026-10-13 06:55:02 +02:00
Jason Baldwin 6246ea67a4 LRC-SS-PLAYBACK: during a slideshow the toast shows at the top, clear of the playback controls (toasts take the pointer since #7ccff3b9) 2026-10-13 06:55:01 +02:00
bflatastic 7693cbf3ae Revert "Merge PR #1262": the toast as a widget takes the clicks meant for what is under it (a dialog's button) 2026-10-13 11:54:39 +07:00
Jason Baldwin 5c41859778 Merge remote-tracking branch 'origin/main' into slideshow-playback 2026-10-13 06:53:37 +02:00
bflatastic 2e877ad476 Batch 32: arrows over a slider nudge it (#1264), the toast area doesn't fade in (#1262), #1282's test runs on Windows 2026-10-13 11:42:47 +07:00
radialmonster 2d28ea81bd LR-SIDEBAR-LOCAL-SYNC: Synchronize Folder in the Local folder menu
A Local folder that holds library photos (or has a folder with some inside)
now offers Synchronize Folder... like the Folders section. The check reads
the cached Folders tree only. Headless test covers shown and not shown.
2026-10-13 00:37:53 -04:00
Brandon Thomas b709180587 Merge pull request from storytold/claude/integrate-batch-32 (batch 32a)
Integration batch 32a: 8 reviewed contributor PRs
2026-10-13 00:27:00 -04:00
sanyamsingh001andCodex 056b0f7a28 LR-MASK-OBJECTS: open the configured model folder and preserve retry
Open the configured SAM directory itself and preserve the pending mask
action and existing files when folder creation or the opener fails.
Run the Linux desktop opener in a background task and deliver failures
to the UI for retry. Directory creation remains synchronous.

Cover configured paths, creation and opener failures, retry state and
worker completion.

Partially addresses #366; model download hosting remains unresolved.
Credit: sanyamsingh001

Co-authored-by: Codex <[email protected]>
2026-10-13 04:22:23 +00:00
bflatastic 1d70cf6358 Batch 32: the import tool tests date their fixtures (undated files take the file's date, #1216) 2026-10-13 11:20:47 +07:00
Brandon ThomasandClaude Opus 5.5 2f0931d3ca Batch 32: rustfmt after merges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:13:49 -04:00
Brandon Thomas a1035f1372 Merge PR #997 2026-10-13 00:13:41 -04:00
Brandon Thomas d5b8d52e1d Merge PR #1262 2026-10-13 00:13:41 -04:00
Brandon Thomas 6568a7ae16 Merge PR #1260 2026-10-13 00:13:40 -04:00
bflatastic d752076339 Batch 32: fixes for PRs merged together (#984 leap-year lint, #1344 ColorData field, #1187 preset panel builds) 2026-10-13 11:11:02 +07:00
bflatastic 702f90bfc9 Batch 32: close the tests two merges added at one place; cargo fmt 2026-10-13 11:08:16 +07:00
bflatastic fa3dfbbc4a Merge PR #1238
# Conflicts:
#	crates/pipeline/src/spots.rs
#	crates/ui-egui/src/panels/edit.rs
#	docs/parity-checklist.md
2026-10-13 11:06:46 +07:00
bflatastic 84d6849cf3 Merge PR #1216
# Conflicts:
#	crates/engine/src/files.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
2026-10-13 11:06:28 +07:00
bflatastic ed1d641f22 Merge PR #1174
# Conflicts:
#	crates/catalog/src/lock.rs
#	crates/pipeline/src/spots.rs
#	docs/parity-checklist.md
2026-10-13 11:06:26 +07:00
bflatastic 692ab7f7c2 Merge PR #1235
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:05:55 +07:00
bflatastic 32253d862a Merge PR #1017
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-13 11:05:54 +07:00
bflatastic 4e78514948 Merge PR #1324
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-13 11:05:53 +07:00
bflatastic bac147a460 Merge PR #1189
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:05:48 +07:00
bflatastic 2181c69162 Merge PR #1151
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:05:47 +07:00
bflatastic 963d4f327a Merge PR #1114 2026-10-13 11:05:45 +07:00
bflatastic 97f8b8c87c Merge PR #1024
# Conflicts:
#	crates/ui-egui/locales/es.json
2026-10-13 11:05:45 +07:00
bflatastic 8df468bfc6 Merge PR #1246
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/parity-checklist.md
2026-10-13 11:05:44 +07:00
bflatastic 8ca6a835ec Merge PR #1411
# Conflicts:
#	crates/ui-egui/locales/pt-br.json
#	docs/whats-new.md
2026-10-13 11:05:35 +07:00
bflatastic 251397694e Merge PR #1302
# Conflicts:
#	crates/preview/src/tests.rs
2026-10-13 11:04:34 +07:00
bflatastic 94808da369 Merge PR #1250
# Conflicts:
#	crates/ui-egui/src/tests_panels.rs
2026-10-13 11:04:33 +07:00
bflatastic edfe7c6351 Merge PR #1242
# Conflicts:
#	crates/ui-egui/src/tests_panels.rs
#	docs/parity-checklist.md
2026-10-13 11:04:32 +07:00
bflatastic 95d519ae64 Batch 32: cargo fmt 2026-10-13 11:04:18 +07:00
bflatastic 1c473ed64c Merge PR #1361 2026-10-13 11:03:53 +07:00
bflatastic cb8aa0b017 Merge PR #1341 2026-10-13 11:03:11 +07:00
bflatastic d3c024e4df Merge PR #1047
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
2026-10-13 11:03:10 +07:00
bflatastic 74a0b5796a Merge PR #1070
# Conflicts:
#	crates/pipeline/src/spots.rs
2026-10-13 11:03:05 +07:00
bflatastic 4764e85f37 Merge PR #1092 2026-10-13 11:03:01 +07:00
bflatastic 0ab59399c8 Merge PR #1186 2026-10-13 11:03:00 +07:00
bflatastic 6bbceb2fa8 Merge PR #1293 2026-10-13 11:02:59 +07:00
bflatastic d6583cebf8 Merge PR #1294 2026-10-13 11:02:59 +07:00
bflatastic cc9deaf64b Merge PR #1078 2026-10-13 11:02:58 +07:00
bflatastic 76fd9b9ae8 Merge PR #805
# Conflicts:
#	docs/camera-preview-colour.md
#	docs/parity-checklist.md
2026-10-13 11:02:58 +07:00
bflatastic 62b1ba04d0 Merge PR #1367 2026-10-13 11:02:55 +07:00
bflatastic e4873dd480 Merge PR #1262 2026-10-13 11:02:54 +07:00
bflatastic 9b4047b1d4 Merge PR #661 2026-10-13 11:02:53 +07:00
bflatastic ac19978d37 Merge PR #1182 2026-10-13 11:02:53 +07:00
bflatastic 6eb04f9a1c Merge PR #1119
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:02:52 +07:00
bflatastic a681a3c667 Merge PR #1076 2026-10-13 11:02:50 +07:00
bflatastic c7e259b3ce Merge PR #1297
# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-13 11:02:49 +07:00
bflatastic bcbcf3b968 Merge PR #1357
# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-13 11:02:48 +07:00
bflatastic e6ef0aa368 Merge PR #1253 2026-10-13 11:02:46 +07:00
bflatastic 695eb6acc5 Merge PR #1282
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:02:44 +07:00
bflatastic 5475691ea4 Merge PR #1353 2026-10-13 11:02:41 +07:00
bflatastic 5e430d057f Merge PR #1301 2026-10-13 11:02:40 +07:00
bflatastic b089188ee5 Merge PR #1358 2026-10-13 11:02:40 +07:00
bflatastic 9e0f00c557 Merge PR #1222
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:02:40 +07:00
bflatastic a0b1da61aa Merge PR #1338 2026-10-13 11:02:38 +07:00
bflatastic e9892a6f9d Merge PR #985 2026-10-13 11:02:38 +07:00
bflatastic b297049e52 Merge PR #984 2026-10-13 11:02:37 +07:00
bflatastic 54c4549fe9 Merge PR #1187 2026-10-13 11:02:37 +07:00
bflatastic 14e2a480f0 Merge PR #1036 2026-10-13 11:02:36 +07:00
bflatastic 33f03ba0f7 Merge PR #1094
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/hardware-parity.md
2026-10-13 11:01:54 +07:00
bflatastic 18bd8fd881 Merge PR #1397
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:01:53 +07:00
bflatastic a39567306b Merge PR #1071
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:01:52 +07:00
bflatastic 14b111f6f5 Merge PR #1344
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 11:01:49 +07:00
bflatastic d3d55e8327 Merge PR #1413
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:01:48 +07:00
bflatastic ecf1236b7c Merge PR #1150
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:01:46 +07:00
bflatastic f28b9c46d3 Merge PR #1051
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-13 11:01:45 +07:00
bflatastic 6aabe43386 Merge PR #1023
# Conflicts:
#	ROADMAP.md
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-13 11:01:44 +07:00
bflatastic b9b6bf57de Merge PR #1020
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:01:43 +07:00
bflatastic 64b3361125 Merge PR #1340
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 11:01:42 +07:00
bflatastic 44684b7950 Merge PR #1288
# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-13 11:01:41 +07:00
bflatastic 61c3042efe Merge PR #1280
# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
2026-10-13 11:01:40 +07:00
bflatastic 2fc2419fa1 Merge PR #1193
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 11:01:37 +07:00
bflatastic 5cec4d0720 Merge PR #992 2026-10-13 11:01:10 +07:00
bflatastic b3860eb734 Merge PR #1291 2026-10-13 11:01:08 +07:00
bflatastic 931b0dd067 Merge PR #1270 2026-10-13 11:01:08 +07:00
bflatastic fa7cd628e5 Merge PR #1292 2026-10-13 11:01:07 +07:00
bflatastic 25aff046f3 Merge PR #982 2026-10-13 11:01:07 +07:00
bflatastic 9f84c3b60d Merge PR #1179 2026-10-13 11:01:06 +07:00
bflatastic 8e808e8d92 Merge PR #1090 2026-10-13 11:01:06 +07:00
bflatastic 690eefb088 Merge PR #1314 2026-10-13 11:01:05 +07:00
bflatastic 536b4fffec Merge PR #986 2026-10-13 11:01:03 +07:00
bflatastic b01902bbae Merge PR #1091 2026-10-13 11:01:02 +07:00
bflatastic dca358d435 Merge PR #1389 2026-10-13 11:01:01 +07:00
bflatastic 1543fef701 Merge PR #1268 2026-10-13 11:01:01 +07:00
bflatastic ac19325c57 Merge PR #978 2026-10-13 11:01:01 +07:00
bflatastic 3ba3120637 Merge PR #1064 2026-10-13 11:01:00 +07:00
bflatastic b56f052a59 Merge PR #1387 2026-10-13 11:00:58 +07:00
bflatastic 5388d20088 Merge PR #1125 2026-10-13 11:00:56 +07:00
bflatastic b2a5997e55 Merge PR #1412 2026-10-13 11:00:53 +07:00
bflatastic 43799cc903 Merge PR #1296 2026-10-13 11:00:48 +07:00
bflatastic a6369c4009 Merge PR #1264 2026-10-13 11:00:47 +07:00
bflatastic 6435019603 Merge PR #1394 2026-10-13 11:00:47 +07:00
bflatastic 99e9f25796 Merge PR #1101 2026-10-13 11:00:47 +07:00
bflatastic 179461aee3 Merge PR #1368 2026-10-13 11:00:45 +07:00
bflatastic 3e5439cc94 Merge PR #1177 2026-10-13 11:00:44 +07:00
bflatastic c9e12bd9ff Merge PR #1123 2026-10-13 11:00:41 +07:00
bflatastic cd75e11841 Merge PR #1149 2026-10-13 11:00:41 +07:00
bflatastic 5fddd79583 Merge PR #1139 2026-10-13 11:00:40 +07:00
bflatastic 035c378758 Merge PR #1052 2026-10-13 10:39:12 +07:00
bflatastic 9fe9beffbc Merge PR #1007 2026-10-13 10:38:47 +07:00
bflatastic e325de9db4 Merge PR #1018 2026-10-13 10:38:46 +07:00
bflatastic 751d25a7f7 Merge PR #1003 2026-10-13 10:38:45 +07:00
bflatastic 6a122dc684 Merge PR #990 2026-10-13 10:38:44 +07:00
bflatastic ae029e0851 Merge PR #997 2026-10-13 10:37:48 +07:00
Brandon Thomas f2a9888f75 Merge PR #1257 2026-10-12 23:31:21 -04:00
Brandon Thomas 765e94e0a1 Merge PR #1255 2026-10-12 23:31:19 -04:00
Brandon Thomas 1f1374fd46 Merge PR #1254 2026-10-12 23:31:17 -04:00
Brandon Thomas ade1a86a03 Merge PR #1253 2026-10-12 23:31:09 -04:00
Brandon Thomas 79e0e98321 Merge PR #1250 2026-10-12 23:30:47 -04:00
eliets2 a10bc04b8b smart previews: store proxies at JPEG quality 97 and version the encoding (#709)
The proxy's largest visible error against the original was the JPEG encoder's
quantised 8x8 DCT DC - each block's own mean, luma and chroma - which shows as
block steps across the frame and a small constant colour shift, and grows with
saturation. Quality 92 -> 97 shrinks those quantisers to about a third (block
-mean dE76 0.138 vs 0.202 on the fidelity test's smooth scene), at about twice
the file size; what remains is the 8-bit sRGB quantisation both encoders share,
which only a lossless proxy format can close.

The header gains an `enc` field (encode version 2) and the folder's look marker
becomes "<look> <enc>": a proxy or marker from before the encode version is
rebuilt/rescanned once, so existing libraries pick the new quality up without
any command. Old files still decode bit-identically (the scale mapping is
untouched, decode ignores `enc`), and a new proxy read by an older build is
left alone by its staleness check.

The fidelity test pins the gain with a frozen copy of the old encoder and a
block-mean dE76 metric (per-pixel dE is dominated by the shared 8-bit floor);
the 0.8 bound is the geometric midpoint between the no-op ratio 1.000 and the
0.632 floor cap the 8-bit quantisation sets on this scene.
2026-10-13 04:55:40 +03:00
dev-null-undefinedandClaude c8f676252f M5.6: with a GPU, a slider drag's preview is drawn in the frame of the drag tick
A drag tick requested the loupe's draft and the frame ended; the draft,
done in 1.5-3 ms on the GPU, was picked up at the start of the next frame,
a frame after the slider moved. Now, in a drag, the loupe waits up to 40 %
of a frame for its draft when the last loupe render took well under that
(not with a render offload, nor zoomed in, where what changes on screen is
the window's draft). A CPU render, which would only make the frame late, is
not waited for.

JobPool::wait waits for the next finished job, which try_recv then hands
out; Renderer::poll's collection of finished jobs is split out so the wait
can turn the draft into the texture.

Co-Authored-By: Claude <[email protected]>
2026-10-13 03:40:23 +02:00
Nathaniel LedfordandClaude Opus 5.5 58822f6229 LR-BEHAV-LOCALIZE: strong text is heavier in scripts whose fallback face is a variable font
The semibold family had a semibold Inter but, for other scripts, the same
regular craft-fonts faces as regular text, so a heading or the chosen tab
in Arabic was drawn at regular weight beside bold Latin.

A fallback face with a weight axis (today Noto Sans Arabic, 100 to 900)
is now registered a second time for the semibold family at weight 600,
through epaint's FontTweak::coords. The bytes are borrowed from the
embedded font, not copied. Faces with one fixed weight, regular and
monospace text, the order of fallbacks and Latin are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:15:57 -04:00
Claude 4c1dfc5b0f LR-MERGE-HDR: keep the reference frame's lens corrections in HDR merges
Merged HDR DNGs lost the camera's embedded lens correction: load_frame strips the lens opcodes from each
source (frames are merged uncorrected) and write_linear_dng wrote no OpcodeList3, so Optics ▸ Enable Lens
Corrections did nothing on the result (Sony ILCE-7M3/7RM4A, Panasonic/Leica, DNGs with their own opcodes).

Each raw frame now keeps the lens corrections it applies (the DNG 1.6 skip rule already applied),
re-expressed for the default crop develop keeps by the new Opcode::cropped: the centre moves into the crop
and coefficients are rescaled for the crop's farthest corner. HdrResult carries the reference frame's and the
DNG writes them as its OpcodeList3. Frames turned before merging (mixed orientations) and panoramas carry none.

Tests: cropped corrections give the crop of the corrected whole (WarpRectilinear, WarpRectilinear2 with and
without reciprocal, FixVignetteRadial); an HDR merge of cropped, corrected brackets, upright and vertical, gives
the same profile correction as its reference photo (fails without the fix). Real brackets not measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 01:05:26 +00:00
Marco Arnulfo 77d8558fc4 LR-CROP-STRAIGHTEN: a crop shrunk to fit by Straighten grows back as the angle returns (fixes #1388) 2026-10-13 02:55:39 +02:00
Marco Arnulfo 0fa8070647 AI Denoise: Install from file says why a model file is refused (fixes #1381) 2026-10-13 02:33:10 +02:00
dev-null-undefinedandClaude 1c70b24c74 M5.7: perf harnesses for slider latency, zoom windows and photo loading (examples only)
- drag_latency (ui-egui example): the whole app driven headless at 60 frames a second; per develop control,
  the latency of each drag tick until its draft is the loupe (or, with ZOOM, the zoom window) texture, the
  ticks never shown, the UI thread's frame time and the time to the full-quality render after release.
- drag_tick_bench (engine example): the UI-thread work of one drag tick outside the render.
- render_bench: ONLY=window (a 1:1 zoom window cut from the original: first window, pan, slider changes;
  GPU_ONLY=1, DUMP=file for pixel A/B), ONLY=load / load1 / jpeg (+ LOADONLY=1): the steps of opening a photo.
  The window rows carry no colon in their names, so `cargo xtask bench` records each of them (parser test).
- open_timeline (ui-egui example): opening raws in Detail through the real app logic, headless with the GPU.

Co-Authored-By: Claude <[email protected]>
2026-10-13 02:21:46 +02:00
Marco Arnulfo e1b29a3fd4 LR-IMP-CAMERA-COVERAGE: Nikon D7100 NEFs develop at Lightroom's frame, so its crops line up (fixes #1248) 2026-10-13 02:16:52 +02:00
dev-null-undefinedandClaude f47fb86077 LR-BEHAV-GPU: say once when no GPU is found, instead of quietly rendering on the CPU
With GPU rendering on and no graphics adapter at all (wgpu: "no GPU adapter among …", e.g. no
Vulkan loader: a `cargo run` outside `nix develop` on NixOS), every render ran on the CPU, much
slower, and only Settings ▸ Performance, Help ▸ System Info and `perf.gpuReason` said why.

The desktop app now queues one start-up notice per session once the device has been tried
(`gpu::ready()`, never blocking): "No graphics processor found: LightCraft renders on the CPU, which
is much slower. Details in Help → System Info.", on Linux with "On NixOS, start it from `nix
develop`." (→, not ▸: no installed font paints ▸.) `panels::notices::gpu_notice` decides from the
reason: nothing when the GPU was turned off (preference, LIGHTCRAFT_GPU, LIGHTCRAFT_GPU_BACKEND=off),
only a software adapter was skipped, or it failed later. Headless sessions and tests never show it
(`app.gpu_notice` is set by the desktop app only). Translated in all nine catalogs; one README
sentence in the Nix section.

Co-Authored-By: Claude <[email protected]>
2026-10-13 01:58:40 +02:00
Marco Arnulfo 713d81d64b KEY-SLIDER-TYPE: a typed slider value keeps editing after the click is released (fixes #1362)
egui gives the opening release's click to the readout, so the field under it surrenders the focus. Only the field's first two frames took it back: a click held any longer ended the edit, and the value could only be typed while holding the button.
2026-10-13 00:51:05 +02:00
Marco Arnulfo cd3a942376 LR-EDIT-LIGHT-CURVE-POINT: ⌥-drag moves a curve point a tenth as far, for fine adjustment (fixes #1363) 2026-10-13 00:47:34 +02:00
dev-null-undefinedandClaude dead4367b0 M5.6: the pinned original no longer evicts the photo's other sources (64 MP edits decoded the raw again)
MediaCache::enforce_budget never evicts the original of the photo on screen (set_on_screen), but it still
counted it. A 64 MP original (Sony A7R VI: 9984×6656 linear float, 760 MiB) nearly fills the default cache
share (768 MiB) alone, so the loop evicted everything else instead: the photo's preview and thumbnail sources,
the rendered previews and the prefetched neighbours. Every edit at 1:1 (the whole-frame render and the
thumbnail), every zoom out and every step to the next photo then decoded the raw again.

The pinned original is now left out of what is compared with the budget. The rest still fits the share; the
original sits over it, as the first commit of this PR already allowed.

Co-Authored-By: Claude <[email protected]>
2026-10-13 00:41:09 +02:00
dev-null-undefinedandClaude a99c084805 M5.6: a slider drag no longer decodes the edited photo's thumbnail
Each drag tick changed the edited photo's settings, so the filmstrip asked
for a new thumbnail render every frame. For an unedited raw shown with its
embedded preview, the thumbnail-level source had never been decoded, so each
of those renders decoded the whole raw (a 64 MP ARW: over a second), several
side by side, and the loupe's drafts queued behind them.

- The edited photo's thumbnail is not re-rendered during a slider drag while
  it has one; it follows once the drag ends.
- A thumbnail of a photo whose preview-level source is decoded (the one in
  the loupe) is cut from that source (SourceRef::Fit) instead of decoding the
  file again; not when the photo has a denoised picture.

Co-Authored-By: Claude <[email protected]>
2026-10-13 00:29:45 +02:00
MennoandClaude Opus 5.5 60639aa677 LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their filler columns (no pink band)
The D90 writes 4352 x 2868 samples whose last 42 columns (from 4310) are
filler at a flat ~254, above its dark image samples (black 0). The
masked-column test only trims columns near zero, so they stayed in the
image and developed as a pink band on the right. Measured on 10 of 550
NEFs from one body: the camera's 4288 x 2848 JPEG sits at (10, 10), and
green saturates at 3840-3851 while red and blue run on to 4095, so one
white level of 3830 keeps clipped highlights white. The D90 joins the D4
in MEASURED_BODIES; other bodies are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:25:33 +02:00
Marco Arnulfo f0d1ea9c4e Before/After wipe: the line stays in the middle of the view when zoomed in (fixes #1351)
A wipe drew its line through the middle of the photo. Zoomed in, that
point is usually off screen, so the whole view fell on one side of it
and showed only the Before (the original) or only the After. The line
now goes through the middle of the part of the photo on screen: the same
place at Fit, and in view at any zoom.
2026-10-13 00:14:43 +02:00
Marco Arnulfo c07035c444 CR2: as-shot white balance of the PowerShot G9 from its WBInfo preset rows (part of #1273) 2026-10-13 00:04:47 +02:00
Marco Arnulfo 3480c99b3e macOS: closing the window ends the process in on_exit, as menu Quit does (fixes #1331)
The close button let eframe drop the window while AppKit's run loop was
still running; AccessKit restored the content view's class and AppKit's
Touch Bar KVO observer threw on the next display cycle. on_exit now ends
the process on macOS once settings and library are saved and background
work has stopped, as #444 does for Quit from the menu bar. Same fix as
storytold/gridcraft#117.
2026-10-12 23:58:13 +02:00
Marco Arnulfo 5eee65edac ORF: black level from the main maker note for bodies without ImageProcessing; gate and bound the 0x1017/0x1018 gains (part of #1273) 2026-10-12 22:48:34 +02:00
PiMinusMaxandClaude Opus 5.5 d9006c96f6 LR-EDIT-COLOR-TEMP: Kelvin white balance from Sony's white-balance presets
Sony ARWs of cameras without measured colour matrices were edited on the
relative scale (6500 K / 0 = as shot). Their SR2SubIFD stores the
multipliers of the camera's presets at 2500, 3200, 4500, 6000 and 8500 K;
lightcraft_raw::kelvin_presets reads the as-shot multipliers on the curve
through them and they take the spectral matrices' place in the #730
design (as-shot Kelvin and tint only; look, Bradford adaptation and the
pixel-identical legacy upgrade unchanged).

Covered: ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S.
Kelvin-dial shots read back 3400 -> 3396, 5200 -> 5193, 4700 -> 4708,
5600 -> 5594 K; Shade 7493 K. Off-curve constants (isotherm tilt 0.74,
tint spacing 1) measured from the 14 Sony bodies with both spectral
matrices and presets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:37:22 +02:00
sanyamsingh001andCodex 6a82142d69 GH-251: document controller polling and edit interactions
Document polling of selection/settings/control ranges and shared edit
interactions. Clarify explicit-photo reads, empty selection and navigation,
and correct photo-tool ID descriptions and MCP initialization guidance.
Partial documentation work for issue 251;
hardware feedback remains outside this change.

Co-authored-by: Codex <[email protected]>
2026-10-12 20:33:21 +00:00
Alex ChambersandCodex 1926c9029f LR-PROF-CAMERACOLOR: cover the held-out toe error gate
Add correlated dark holdouts that reject misleading training and accept
matching training. Check finite, monotone curves in both cases. Confirm
the regression fails with the error gate removed and passes when restored.

Qualify the improvement claim when no original curve can be fitted, and
explain the source-look process policy and offline smart preview refresh.

Co-Authored-By: Codex <[email protected]>
2026-10-12 22:27:31 +02:00
Alex ChambersandCodex c11764a12a LR-PROF-CAMERACOLOR: fit the camera toe from dark JPEG tones
Use separate luminance pairs for tone fitting so the camera look follows
its JPEG into deep shadows. Keep colour pairs and holdouts unchanged.
Require enough correlated shadow samples and lower held-out error, with
noise and compression floors and the existing median curve fit.

Add synthetic regressions, bump look and render cache versions, and
record the public multi-make and private numeric validation in the docs.

Fixes #1307

Co-Authored-By: Codex <[email protected]>
2026-10-12 21:50:24 +02:00
Marco Arnulfo 07d7d5d498 M-export: a resized export keeps the edge it was asked for (fixes #1312)
Resize::apply rounds the size, then the render fit the frame into that box
again: on a frame a hair under 3:2 the refit gave 1333 x 1.4999999 = 1999.49
and lost a pixel of the long edge. Frame::fit now keeps a box that already has
the frame's aspect up to rounding, and output_size rounds the native size to
whole pixels first so float noise can't decide a half-pixel tie.
2026-10-12 21:05:42 +02:00
Marco Arnulfo 8c539426eb Docs: CR2 white level from the active area (#1272), 60D, 450D and 550D affected too 2026-10-12 21:02:48 +02:00
Marco Arnulfo d41de88c05 LR-IMP-FORMATS: active_rows steps an odd number of rows, so a plateau on red or blue sites alone is found (#1272) 2026-10-12 21:02:48 +02:00
Marco Arnulfo dd502bac9a LR-IMP-CAMERA-COVERAGE: Pentax PEF masked borders in raw-parity, the checklist and what's new (#1271) 2026-10-12 20:37:31 +02:00
Marco Arnulfo 13e803ab07 LR-IMP-CAMERA-COVERAGE: PEF trims masked lines a little above black, saturated lines outside masked ones, and keeps a lens-cap frame whole (#1271)
Masked lines are measured against the outermost line's level when it sits
just above BlackPoint (K-70, K-1), saturated outer lines count when dark
ones follow (645Z), and a frame whose interior is within the tolerance
keeps its whole area. Line means read samples with get(). Render cache
version 62: PEF frames change size.
2026-10-12 20:37:31 +02:00
Nathaniel LedfordandClaude Opus 5.5 f0862221c1 Issue #989: format the lock test (the shorter command fits on one line)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:48:35 -04:00
Nathaniel LedfordandClaude Opus 5.5 ef3e50bb2d Issue #989: the lock test finds true on the PATH (macOS has no /bin/true)
The spawning thread ran /bin/true, which is /usr/bin/true on macOS, so
it never spawned a child and the test failed there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:48:34 -04:00
untypequicodeandClaude Sonnet 5.5 7aaeef2676 Nix: fix the flake at HEAD (git dependency) and load libdbus/libEGL on NixOS
Cargo.lock now pins storytold/craft-libs by git revision, so the flake failed at
evaluation with "No hash was found while vendoring the git dependency"
(cargoLock.allowBuiltinFetchGit fixes it).

rfd dlopens libdbus-1 for the xdg-desktop-portal file chooser and wgpu may
fall back to libEGL: neither is on NixOS's default library path, so add dbus
and libglvnd to the wrapper's LD_LIBRARY_PATH.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:13:04 +02:00
Alex ChambersandClaude Opus 5.5 ecd6618f6f LR-IMP-FORMATS: uncompressed Leaf MOS is a raw, the Hasselblad Lunar decodes as an ARW (#1275)
An uncompressed Leaf Aptus 22 MOS keeps its 16-bit mosaic in IFD0
(compression 1, BlackIsZero, one sample per pixel) with no Make tag and
no CFA photometric, so `probe` returned None and the file imported as a
greyscale image of the mosaic. Leaf's data block, tag 0x8606 (LeafData
in the ExifTool EXIF tag-name documentation), now marks such an IFD0 as
a raw: OtherTiff, preview only with the reason, as the lossless-JPEG MOS
already is. A rendered RGB image that kept the block stays an image.

The Hasselblad Lunar is a Sony NEX-7 that writes an ARW under Make
HASSELBLAD; only SONY files reached the ARW reader, so it opened as its
preview. A HASSELBLAD file with Sony's raw coding (an IFD with
compression 32767 or the tone-curve tag 0x7010, which no 3FR / FFF has)
now goes there and decodes like a NEX-7: black 512, the SR2SubIFD white
balance. The Tag2010 white-balance fallback, the one table arw.rs keys
by model, takes the body from the maker note's SonyModelID (0xb001)
when the file is not Sony-branded.

Docs: the Lunar is verified in docs/raw/evidence.tsv (236 verified),
raw-parity.md, parity-checklist.md, gaps.md and whats-new.md.
RENDER_CACHE_VERSION 61 -> 62: a Lunar's thumbnails were rendered from
its preview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:02:19 +02:00
radialmonster 0391362f2f Format the DNG tone fit with rustfmt 2026-10-12 12:48:47 -04:00
Matthew James Sivak 35ade2dfbf Merge remote-tracking branch 'origin/main' into worktree-fix-1239-confirm-delete 2026-10-12 18:41:36 +02:00
radialmonster f89b70e1c1 Tone-only DNG fit: train on dark pixels, compare highlights with the camera preview too 2026-10-12 12:37:45 -04:00
radialmonster 1a5c4665da Fit a DNG's tone to its embedded preview when the file defines none
For a DNG with its own colour matrix, an identity or absent ProfileToneCurve, no
LookTable and no ProfileGainTableMap, fit only the tone and chroma curves to the
camera JPEG, with the file's colour model untouched (profile tables and embedded
vignetting applied to the sensor proxy as the loader renders it). The fit is used
only when the preview is at least 90% of the raw's long edge, shows the same
picture (structure gate), passes the existing held-out acceptance, and does not
put more of the photo near white than the plain render (highlight gate, at 2x
proxy luminance). Otherwise the render is unchanged. Other formats and
catalogued photos are untouched. Render cache version 62.
2026-10-12 12:37:45 -04:00
Matthew James SivakandClaude Opus 5.5 d7037d117f M5.6: a view slot renders one job at a time, so a drag's renders don't pile up
The job pool replaced a slot's queued job, but a newer job for a slot whose job was
already running started beside it on another worker. A slider drag submits faster
than renders finish, so one view had several renders on the GPU at once and about
30 % of finished renders were thrown away (61 MP raw, 1:1 Exposure drag). Now a
slot's next job waits until its running one ends (a newer submission still
replaces it in the queue); other slots are not held up, and a job that panics
frees its slot.

Measured on a 61 MP raw at 1:1, 8 s Exposure drags (M4 Max): thrown-away renders
roughly halved, and frames over 20 ms 0 % in every one of 4 runs (one run of the
overlapping pool dropped 10 %).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:20:15 +02:00
Marco Arnulfo acbd08eb73 LR-LIB-DELETE: a file in Recently Deleted can be checked by hand in the import review (fixes #1285)
With "Leave them", the review refused every click on a file whose photo is in
Recently Deleted, and its "In Recently Deleted" badge was drawn over the
checkbox. Leave them now only keeps such files out of the default selection
(Check All and the file-type toggles skip them); a click checks one and it is
restored, as the import does by default. The badge goes under the checkbox
when it doesn't fit beside it.
2026-10-12 18:14:05 +02:00
Andrew FrahnandClaude Opus 5.5 6a5158d4ed i18n: translate dialog notes, toasts and tooltips (Contact Sheet, HDR export, slideshow, auto advance, import tokens)
Refs #911

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 03:01:31 +11:00
Matthew James SivakandClaude Opus 5.5 e343b7aa6d fix: right-click Delete Photo goes through the menu bar's run_item (#1239)
Simplify review: the photo menu's Delete Photo now calls
menubar::run_item, the one place a user's menu click is run (it already
asks before photo.delete), instead of a second copy of the check. The
test uses the file's right_click / click / exec helpers and drops a
redundant settle; shorter doc comment on the setting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:41:55 +02:00
Marco Arnulfo 73a10f5b6d ORF: as-shot white balance from maker-note 0x1017/0x1018 on the Olympus E-10 (part of #1273) 2026-10-12 17:41:40 +02:00
Markus FriedelandClaude Opus 5.5 978132fdca LR-EDIT-COLOR-TEMP: the Kelvin Temp slider has Lightroom's scale, 7000 K in the middle (#1295)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:37:42 +02:00
Marco Arnulfo a9812d0cb4 PEF: the dark-border tolerance is relative to the black level, not the interior, so dark real content stays (#1271) 2026-10-12 17:34:31 +02:00
Andrew FrahnandClaude Opus 5.5 fa28792363 fix(i18n): translate Remove panel spot mode and dust toast
Refs #911

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 02:22:07 +11:00
Andrew FrahnandClaude Opus 5.5 c56fa85a98 fix(i18n): translate Keep in Local / Add to Local
Refs #911

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 02:22:07 +11:00
Andrew FrahnandClaude Opus 5.5 fbfc1b0296 fix(i18n): translate the curve right-click menu
Refs #911

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 02:22:03 +11:00
Andrew FrahnandClaude Opus 5.5 e79027f728 fix(i18n): translate Compare and Reference view captions
Refs #911

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 02:21:55 +11:00
Marco Arnulfo 6c033ea5dc PEF: masked rows and columns at the black level are trimmed without crop tags (fixes #1271) 2026-10-12 17:21:13 +02:00
Marco Arnulfo af269b1104 LR-IMP-FORMATS: CR2 white level from the active area, not the saturated masked rows above it (fixes #1272) 2026-10-12 17:16:04 +02:00
Christos SidiropoulosandCodex GPT-5.6 Sol 8a4fec438a Fix #1194: locate missing files by folder tree
Co-Authored-By: Codex GPT-5.6 Sol <[email protected]>
Signed-off-by: Christos Sidiropoulos <[email protected]>
2026-10-12 17:15:54 +02:00
Markus FriedelandClaude Opus 5.5 a940fae193 MENU-CTX-ALBUM: an album's Import ▸ Import from Device lists the devices found (#1267)
Without a path, file.addFromDevice took the first device found, so with
two cards connected the album menu imported from whichever came first.
Like File ▸ Import from Device, the album's Import from Device is now a
submenu with one entry per camera or card (or "No Camera or Card
Found"), each passing its path along with the album.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:52:06 +02:00
Andrew FrahnandClaude Opus 5.5 a234a20876 fix(i18n): translate Photo Merge projection choices, merge toasts and panorama summary
The projection radio labels, the merge kind inserted into the merge toasts and the
panorama preview summary were shown without tr(); the catalogs already had them.

Refs #911

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:50:53 +11:00
Markus FriedelandClaude Opus 5.5 edb3cbfd8a MENU-CTX-ALBUM: Import ▸ submenu on an album opens the review with that album chosen (#1267)
An album's context menu gets Import ▸ with File ▸ Import's ways in
(Import Photos…, Import from Folder…, Import from Device). file.addPhotos,
file.addFolder and file.addFromDevice take {album}; the scan task carries
it and the review chooses it when the scan is done, if it still offers
that album (not deleted, not a folder or smart album).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:45:14 +02:00
Andrew FrahnandClaude Opus 5.5 03a3bbd561 Up / Down move through the grid's rows (#1171)
library.up / library.down (Up / Down) go to the cell above / below in the
grids (nearest by position in justified rows; the top / bottom row stays
put), and step one photo in the filmstrip.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:39:01 +11:00
Gert Pellin 031537ec02 contributors: add @switch87's names, as in the sibling craft apps 2026-10-12 16:29:35 +02:00
Vikram Bhaskaran 20ba473e85 Let the deep-zoom preview margin cover wide local filters. 2026-10-13 01:24:35 +11:00
Vikram Bhaskaran 7b6b25e6ab Composite straight alpha onto white before develop. 2026-10-13 01:24:15 +11:00
Vikram Bhaskaran fbfa87d614 Treat a numeric --connect value as a loopback port. 2026-10-13 01:24:02 +11:00
Vikram Bhaskaran 994c820bec Include HEIC, HEIF, and NRW when a folder is imported. 2026-10-13 01:23:57 +11:00
Vikram Bhaskaran fb725ce33f Scroll the filmstrip when the same photo is shown from another source. 2026-10-13 01:23:51 +11:00
Markus FriedelandClaude Opus 5.5 77ebac813a LR-LIB-ALBUM: "Include selected photos" option in New Album (#1249)
New Album always put the selection into the new album. Like Lightroom's
Create Collection, the dialog now has an "Include selected photos"
checkbox: ticked by default (today's behaviour), shown only when
something is selected and never for a folder. dialog.newAlbum takes
includeSelected; album.create already had addSelected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:15:08 +02:00
Gert Pellin 7ccff3b96e fix(toast): draw the toast as widgets so the message can be selected
The toast was painted straight onto the layer with painter.galley(), and
painted text is never hit-tested: nothing in it could be selected or
copied. A toast often carries the path or the command the user needs
elsewhere ("Exported frame to /path"), and egui's selectable labels —
on by default — only work on real widgets.

Draw it as an Area with a Frame and a wrapping Label instead, keeping the
same centring, 6 px radius, 20/11 padding and the 0.25 s fade-out.

The new tests assert the toast has an area of its own, that the pointer
reaches the text (egui offers the text cursor over it), and that a long
message still wraps within the canvas. All three fail when the toast is
painted, which is what they are for.
2026-10-12 16:14:39 +02:00
Andrew FrahnandClaude Opus 5.5 139e9633cf LRC-DEV-HISTORY: History cursor — going to a step adds no step
Go to History Step (and new Previous/Next History Step commands) loads the
step's settings and moves a per-photo cursor (`Photo.history_cursor`,
`Op::SetHistoryCursor`, catalog format v8; older catalogs open at the latest
step) instead of appending "History: …". Later steps stay listed, greyed in
the History panel, and can be returned to; a new edit made behind the end
drops them first. Each move is one undo step; undoing an edit restores the
dropped steps and the cursor. `history.list` returns `cursor`.

Also: spots.rs uses as_chunks (clippy chunks_exact_to_as_chunks).

Sources: own design (Lightroom Classic / Photoshop History panel behaviour, observed).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 00:58:05 +11:00
Matthew James SivakandClaude Opus 5.5 cf085041ed fix: deleting a photo asks first, also from the right-click menu (#1239)
Cause: Settings > General > "Confirm before moving photos to Recently
Deleted" defaulted to off, so Delete never asked unless the person found
the setting; and the grid/filmstrip photo menu's Delete Photo ran
photo.delete directly, skipping menus::confirm_delete even with the
setting on.

Fix: the setting is on by default; the photo menu's Delete Photo goes
through menus::confirm_delete like the key and the menu bar. The setting
is now saved as `askBeforeDelete`: every earlier version wrote
`confirmDelete: false` for people who never opened Settings, so that key
is no longer read and those installs ask too. Turning it off still works
and survives a restart. ui.menu.invoke / engine.execute (agents) still
run the command directly.

Test: tests_panels::deleting_a_photo_asks_first_from_the_key_the_menu_and_
the_right_click_menu (failed: off by default, then the right-click menu
deleted without asking) and tests_appearance::delete_asks_first_by_default_
also_for_settings_saved_by_earlier_versions (failed: default off).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:39:58 +02:00
JordyUSAandClaude Opus 5.5 1dc86d752c LR-VIEW-HISTOGRAM: the HDR histogram follows slider drags live
A drag renders drafts, which have no HDR histogram, so an HDR edit's histogram dropped to the
SDR layout until the drag ended. Now, during a drag of an HDR edit, a small full-quality render
(Slot::HdrHistogram, 192 px, its own stage cache) runs beside the drafts and feeds the panel at
its own pace: the drafts don't wait for it. The panel also keeps an HDR edit's last HDR histogram
when a render without HDR bins arrives (a draft, a cached thumbnail or stand-in), so it never
swaps to the SDR layout mid-drag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:18:42 +07:00
Robert Woldberg 372080cd5a Merge branch 'main' into lightrroom-import-progress 2026-10-12 07:16:26 -06:00
JordyUSAandClaude Opus 5.5 3c50fd58e6 Clippy 1.99: as_chunks for the Fill spot's completion (chunks_exact_to_as_chunks)
Rust 1.99's clippy rejects `chunks_exact(3)` with a constant size
(`chunks_exact_to_as_chunks`, -D warnings), which failed `cargo xtask ci` on
main. Same behaviour: the completed pixels are copied as [f32; 3].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:01:52 +07:00
dev-null-undefined 63bcaa147f M5.6: a zoomed view keeps the GPU copy of its original while idle (re-uploaded after 3 s)
The idle trim (3 s without renders) released the shared device copy of the original a zoom
window is cut from, so pausing between pans made the next pan upload it again: 800 MB for a
64 MP raw, ~1.4 s of "gpu sample" measured (5 ms when it is still there). While a Detail window
texture is held the renderer is not idle; once the windows are gone (zoomed out, another photo,
or another view, which now frees the Detail view's windows) the trim runs as before.
2026-10-12 14:08:33 +02:00
Matthew James SivakandClaude Opus 5.5 b446a5b829 LR-VIEW-ZOOM: time drag drafts with the frame that shows them, give up early (#330)
- A full-size draft's time is its render plus the CPU time of the frame
  that uploaded it (eframe's cpu_usage, Perf::host_frame_ms): render
  times alone said 231 of 234 1:1 drafts fit 16 ms while no complete
  update did (PR #1009 benchmarks).
- A drag whose full-size drafts take more than twice the budget drops to
  0.6 after three of them instead of at its end (a refined mask ran at
  190 ms a step for the whole first drag).
- Under a zoom window the hidden whole-frame draft is the 0.6 one, and it
  is not timed (the frame's time there is the window's upload).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:06:23 +02:00
Matthew James SivakandClaude Opus 5.5 18f583ca5f LR-VIEW-ZOOM: render workers convert their texture pixels, not the UI thread (#330)
A 10 MP zoom window's ColorImage conversion ran on the UI thread on every
slider step. The pool now hands back the render with its texture pixels;
offloaded (browser) results and results presented again on the UI thread
are still converted there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:06:23 +02:00
dev-null-undefined 33accc3892 M5.6: a zoomed window waits for the original another window is decoding (decoded 2-3 times)
The Before window waited for the original only when the After's window job was already pending,
but side by side the Before is asked for first in the frame, so both decoded the 64 MP raw at
once; and a window asked for again while the first was still decoding (a pan, the layout
settling at startup) started another full decode beside it (three decodes seen at startup).
Any window now waits while a window job is pending and the original isn't held, and follows
from the decoded original.
2026-10-12 13:48:05 +02:00
dev-null-undefined 208aa023b0 M5.6: the Detail photo's original stays over the memory budget (64 MP raws re-decoded on every pan)
A decoded 64 MP original (Sony A7R VI: 9984×6656 linear float, 797 MB) is bigger than the
default cache share (half of 1.5 GiB). MediaCache::enforce_budget evicted it as soon as the
render handed it over, so every pan of a 1:1 view and every slider move while zoomed decoded
the raw again (and lost the shared GPU copy, keyed by the CPU Arc).

The frontend now tells the cache which photo Detail shows (MediaCache::set_on_screen, set per
frame in panels::compare::prepare); that photo's original is not evicted for the budget and is
let go as soon as another photo or view is shown. Keying it on "no newer preview of another
photo" instead does not work: the neighbour prefetch stores previews after the original.
2026-10-12 13:26:08 +02:00
Marco Arnulfo a4716d9e4f LR-IMP-FORMATS: transparent areas develop on white instead of black (#1212)
The decoders keep the alpha plane, but to_working converted only the
colour, so transparent pixels kept the black most encoders store under
them: renders, previews, exports and contact sheets showed it. The
working image is now flattened onto white, in linear light like the
rest of the pipeline.
2026-10-12 13:15:19 +02:00
Andrew FrahnandClaude Opus 5.5 978312b5cc Fix: import of metadata-less exports — file date as capture date, in-source copies not "already in the library", Grid Info from Photo Grid
- Files with no capture date in their metadata or sidecar (e.g. exports saved without
  metadata) take the file's modification time, as Lightroom does, instead of all landing
  under "Unknown Date" (the loupe overlay showed the import date for them). The import
  review and its folder/rename templates use the same date.
- The import review counted files with the same bytes as another file in the source as
  "already in the library" (8 on a brand-new, empty library); they are now shown apart as
  "identical copies".
- View > Grid Info captions are drawn on Square Grid only; choosing one from Photo Grid now
  switches to Square Grid instead of seeming to do nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:32:45 +11:00
Atmik Shetty 64e0395c95 LRC-PRINT-JOB: Print Job colour controls in the Print dialog
The Print dialog's Print Job panel, under Resolution:

- Profile: Managed by Printer (sRGB), Managed by Printer (Adobe RGB), or Other… to pick a printer /
  paper .icc / .icm with the colour-profile file picker the soft proof uses; the picked profile's
  name is shown.
- Intent: Perceptual / Relative; Black Point Compensation (enabled for Relative).
- Print Adjustment: Brightness and Contrast, -100..100.
- Sharpening: Off / Low / Standard / High; Media: Matte / Glossy (enabled while sharpening is on).
- 16 bits per channel.
- A summary line: PDF, profile, depth, current edits.

Each control sends print.set {job: {color}}, so the dialog, CLI, control channel, MCP and print
templates share the settings. UI test: every control reaches the job (the Print Job panel is
scrolled into view first, as a person would), Other… picks a profile from the picker, and the PDF
printed with it carries the output intent at 8 bits.
2026-10-12 15:40:04 +05:30
Atmik Shetty 5cbaf64579 LRC-PRINT-GUIDES: the Print preview stops re-rendering thumbnails every frame
A thumbnail is kept at one size. The page preview asked for each cell's photo at the cell's size
on every frame, and the library grid behind the dialog asked for its own size, so the two replaced
each other's request: while the Print dialog was open a thumbnail render was always in flight and
about two render jobs were rebuilt per frame, keeping a core busy. The preview now uses the
photo's thumbnail when there is one and asks for one only when there is none.

Regression test: with the dialog open in Contact Sheet, Picture Package (the same photo in cells of
different sizes) and Single Image, the dialog settles, no render is in flight and no thumbnail job
is rebuilt over 60 idle frames. Before the fix the job count rose by about 100 every 50 frames.
The Print dialog's UI tests now finish in seconds (they were waiting out each settle timeout).
2026-10-12 15:39:08 +05:30
Atmik Shetty 65775b4716 LRC-PRINT-JOB: colour-managed print PDFs (printer profiles, intent, black point compensation, print adjustment, print sharpening)
print.exportPdf now converts each composed page to the job's colour target and writes it into a
print-ready PDF:

- PrintJob gains `color` (PrintColorSettings): Managed by Printer (sRGB / Adobe RGB, profile
  embedded, no output intent) or a printer / paper .icc / .icm converted here and named as the
  document's output intent; perceptual or relative colorimetric with black point compensation;
  Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte or
  Glossy; 16 or 8 bits per component. print.set / print.get and print templates carry it;
  out-of-range values are refused when set.
- Each photo is sharpened for the paper right after it renders at its print size.
- print/output.rs converts and Flate-compresses one page at a time (memory holds one page's device
  samples) into ICC-tagged images through engine::pdf; the PDF is 1.6. The 256 MiB cap counts the
  compressed pages.
- A printer profile that can't be used (missing, too large, not a profile) fails the print before
  anything renders, naming the file.
- contact_sheet's PDF writer goes back to private: only the contact sheet uses it.

Tests: a printer profile reaches the PDF as ICCBased images plus an output intent, a junk profile
fails naming the file, colour settings validate with the job, sharpening and media round-trip
through print.set / print.get, a missing profile fails before rendering with no activity row;
the existing print and CLI tests follow the PDF 1.6 output. Docs: LRC-PRINT-JOB and gap 15.
2026-10-12 15:16:32 +05:30
Atmik Shetty 9bc76a4e58 LRC-PRINT-JOB: print job colour settings: profile, intent, print adjustment, print sharpening, depth
engine::print_color turns composed pages into a print-ready PDF:

- Profile: Managed by Printer sends the page in sRGB or Adobe RGB with that profile embedded and
  no output intent (the driver converts); a printer / paper .icc / .icm converts the page to the
  device's values here and becomes the document's output intent (named by the profile's
  description), so the driver must not convert again. Profiles over the size cap, unreadable or
  invalid files are errors that name the file.
- Intent (perceptual / relative colorimetric), black point compensation and print adjustment
  brightness / contrast, through codecs::print.
- Print sharpening Off / Low / Standard / High for Matte or Glossy paper, per photo at its print
  size, with the export sharpener.
- 16 bits per component by default, 8 for smaller files.

PrintColorSettings is serde (camelCase, defaults for missing fields) so print templates can save
it. Page sizes are checked with overflow-safe arithmetic. Not wired to the print layout yet.
2026-10-12 15:15:57 +05:30
Atmik Shetty d289fafbe2 LRC-PRINT-JOB: PDF writer for print output (ICC-tagged 8 / 16-bit images, output intent)
engine::pdf writes print-ready documents: pages of any size in points with raster images placed
on them, each image tagged with its ICC profile as an ICCBased colour space (RGB, CMYK or grey),
stored losslessly with Flate at 8 or 16 bits per component (16-bit samples big-endian), or copied
as a JPEG stream where file size matters more. A profile shared by several images is written once.
An optional output intent names the printer / paper profile the file was made for (PDF/X-style
/OutputIntents in the catalog); the Info dictionary carries the title, as UTF-16 when it isn't
plain ASCII.

PDF 1.6 (16-bit images need 1.5), classic cross-reference table, deterministic bytes. Pages, images
per page, page and image sizes are capped; bad sizes, placements, sample counts and component
counts are errors, never panics. Tests read the files back through the xref table: image samples
and profiles inflate to what went in, placements reach the content streams, one profile object is
shared. Not wired to the print job yet.
2026-10-12 15:15:57 +05:30
Atmik Shetty 068caf361c LRC-PRINT-JOB: print colour transform (printer / paper profiles, intent, black point compensation, print adjustment)
codecs::print converts a composed print page (16-bit RGB in the Rec2020 output encoding) to the
samples a print-ready file carries:

- PrintTarget::Profile(icc): a printer / paper profile, RGB, CMYK or grey, matrix or LUT based,
  converted with moxcms; PrintTarget::Managed(space): the printer manages colour and the page goes
  in sRGB or Adobe RGB with that profile embedded.
- Perceptual or relative colorimetric (ProofIntent, as soft proofing).
- Black point compensation for relative colorimetric: the device's darkest is measured by a round
  trip of black through the profile and linear values are scaled to land the page's black there
  (Y' = Y(1 - Yb) + Yb, the published mapping for a neutral destination black), so shadows below
  the ink's black no longer clip to one value. Perceptual tables keep their own black mapping.
- Print adjustment brightness / contrast (-100..100) on the sRGB-encoded values, black and white
  held, monotonic at every setting.

Everything before the profile is per channel and folds into one 16-bit table; pages convert in
bounded batches (parallel with the `parallel` feature). Hostile profiles, half pixels and NaN
settings are errors or neutral, never panics. Not wired to the print job yet.
2026-10-12 15:15:57 +05:30
Atmik Shetty 32b5aefc5b LRC-PRINT-GUIDES: the Print dialog (File → Print…) with page preview, rulers, guides and cell editing
File → Print… (⌘P) opens the Print module as a dialog: settings on the left,
the page drawn to scale on the right with the photos as they will print (fit
or Zoom to Fill, turned by Rotate to Fit, Photo Border, Inner Stroke, page
background).

Settings column: templates (save, apply, delete), layout style, image
settings, grid / package cell controls (standard and custom cell sizes, Auto
Layout, New Page, Clear Layout), rulers and guides (units in/cm/mm/pt, snap
off/cells/grid, grid size, margins, bleed, page grid, image cells,
dimensions), page setup (paper, orientation, margins), page background and
the print job.

Preview: rulers in the chosen unit, dashed margins and bleed, page grid,
dimension labels, page navigation. In the package styles cells are selected,
dragged (print.cell.move) and resized from a corner handle
(print.cell.resize) with the guides' snapping, arranged from the inspector
row or a context menu, and given a photo (Custom Package). Every change is a
print.* command; the drag is measured from where the pointer went down so
the click-versus-drag slop is not lost.

Print to PDF… picks a file and renders on a background thread with an
activity-stack row, cancellation and original protection, like the contact
sheet, which keeps working unchanged.

Headless tests drive the dialog with real pointer input through the control
channel (print:cell:N, print:resize:N, button:print*): styles and checkboxes,
add / drag / resize / arrange / delete, snapping to a neighbour, Auto Layout
and pages, templates, the PDF, the activity row, and that the dialog and its
buttons fit the window.
2026-10-12 15:15:33 +05:30
Atmik Shetty 3b32f82e9e LRC-PRINT-LAYOUTSTYLE: print.* commands for the layout styles, cells, templates and PDF
The Print module's state lives in the session (Session::print), so the app,
CLI, control channel and MCP share one print: layout style (Single Image,
Contact Sheet, Picture Package, Custom Package), page setup, grid, image
settings, page background, guides and job. 18 commands, none journaled
(nothing here touches the catalog):

  print.get / set / reset      set merges a partial patch, validates the whole
                               result and changes nothing on error; a new
                               paper or margin pulls cells back inside
  print.cell.add|move|resize|delete|stack|photo|page
  print.layout.autoLayout|newPage|clear
  print.exportPdf {path, ids?} composes and writes the PDF with an activity
                               row, refuses to replace an original
  print.template.list|save|apply|delete

Templates keep layout, image settings, page and job (not the photos assigned
to cells, not the guides) and are saved in the library's prefs.json next to
the export presets. They are stored as JSON and checked when applied, so a
template from another version or a hostile one is an error on use and never
makes the preferences file unreadable.

Tests drive every command through the dispatcher (hostile numbers, unknown
keys, caps, persistence across reopening a library) and a CLI end-to-end test
chains set / add cells / Auto Layout / PDF in one run.
2026-10-12 14:35:03 +05:30
Atmik Shetty 8336f06d28 LRC-PRINT-IMAGESETTINGS: compose print pages (fit / Zoom to Fill, Rotate to Fit, border, stroke, background)
print/compose.rs turns a layout and its photos into PageRasters: 16-bit
interleaved RGB encoded in Rec. 2020 at the job's pixel density. Each cell's
photo is rendered with the export pipeline at exactly the size the cell needs,
then fitted (whole photo, centred) or zoomed to fill (centre crop), turned a
quarter clockwise when Rotate to Fit makes it print larger (or crop less),
framed by the Photo Border (package styles only) and finished with the Inner
Stroke drawn inside the photo edge. The page background, border and stroke
colours are given in sRGB and converted to the page encoding.

Photos go to cells by layout style: Single Image and Contact Sheet paginate
the selection, Picture Package repeats the first photo, Custom Package prints
each cell's own photo. The job is prepared on the session and run off it with
progress and cancellation, like the contact sheet; page and job pixel counts
and page counts are capped so a hostile resolution or paper cannot exhaust
memory.

print/output.rs holds the interim PDF writer (Rec. 2020 to 8-bit sRGB JPEG
pages sized to the paper in points) through the contact sheet's Pdf, now
pub(crate). PrintJob carries the print resolution (72 to 720 ppi, 240 default).

Tests draw on synthetic images for exact pixels (borders, strokes, crop,
rotation direction, resampling) and on the demo library for whole pages.
2026-10-12 14:28:40 +05:30
Atmik Shetty 4f1ed6df65 LRC-PRINT-LAYOUT: the print layout model (paper, margins, grid, package cells, packer, snapping)
crates/engine/src/print/layout.rs is the pure page model the Print module
builds on: ten named papers plus custom, orientation, margins and the
printable area; Single Image, Contact Sheet (grid with spacing and square
cells), Picture Package and Custom Package styles; package cells with a
stacking order (send to back / backward / forward / bring to front); a
deterministic MaxRects packer behind Auto Layout (largest first, earlier
pages filled before a new one opens, a cell turned a quarter only when it
fits nowhere as given); snap-to-cells and snap-to-grid helpers; ruler units
and guide options.

All lengths are inches in page space, after orientation. Every number from
outside is checked (NaN, infinity, negative, oversized, cell and page caps)
and Layout::validate guards saved layouts.

No command or UI reaches it yet, so the parity rows keep their status.
2026-10-12 14:21:42 +05:30
GabrielePecchioliandClaude Opus 5.5 aec225b6ac Add Italian interface translation (#1192)
Italian (`it`) catalogs: 1,992 interface messages and all 246 format strings
(2,201 of 2,398 messages; What's New left in English like most languages).
410 labels reuse the PhotoCraft, VectorCraft, PdfCraft and WordCraft Italian
catalogs where the meaning fits a photo app; 46 were retranslated.

Counts follow a label ("Foto importate: 3") because Italian adjectives agree
with the number; the English plural suffix is consumed with {:.0}.

Language table entry, menu command, three tests mirroring Ukrainian's, the
button-fit test extended to Italian, and the Settings language buttons now
wrap so an eleventh language doesn't widen the dialog past a short window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:45:26 +02:00
Alex ChambersandClaude Opus 5.5 f1c023ed3d LR-META-XMP: Read Metadata from File doesn't auto-write the sidecar it just read (#934)
With XMP auto-write on, photo.readMetadataFromFile committed what it read
and the command handler then wrote the sidecar back, adding lc:settings to
a sidecar that had only another app's crs: settings. Later reads prefer
lc:settings, so LRTimelapse's next crs: edits were ignored. The read now
sets the existing one-command skip_auto_write switch (as photo.removeRegion
does); edits made afterwards are still auto-written.

Tests: a read leaves the sidecar byte for byte and a later develop edit is
still written; the issue's crs:Exposure2012 0.5 -> 1.2 round trip is read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:24:29 +02:00
LosminekandClaude Opus 5.5 6402c83bb4 contributors: add Losminek
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 08:32:45 +02:00
LosminekandClaude Opus 5.5 358a19c755 i18n: add Czech (cs) interface language
Add the Czech catalog (2,056 UI messages, 246 format templates), the
language-menu entry, locale detection (cs, cs-CZ, cs_CZ.UTF-8) and
regression tests for switching, persistence, dates, placeholders and
glyphs. Translate a few face/model, person and HDR/PDF captions through
the existing tr() interface. Settings > Language now wraps its buttons,
since twelve language names no longer fit a narrow window.

What's New prose stays English, like most catalogs. AI-assisted, with
an independent review pass; not yet proofread by a native speaker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 08:32:45 +02:00
Fabio LussiandClaude Opus 5.5 29ed703794 docs: drop the docs workflows until the checker is on artcraft-services main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 08:09:17 +02:00
树洋 陈 40b7b9b385 fmt: satisfy rustfmt on AllMasks test and detail panel 2026-10-12 14:04:19 +08:00
树洋 陈 92a2e721e2 ui: fix stale Ctrl+O / Cmd+O in mask_overlay_all doc comment
The shortcut moved to Ctrl+Shift+O / Cmd+Shift+O; this doc comment still
advertised the old binding.
2026-10-12 13:41:14 +08:00
树洋 陈 9fc2e69328 gpu: read all mask overlay alphas in a single GPU->CPU readback
For Overlay::AllMasks, the masks buffer already holds each visible mask's
alpha back-to-back in the same order the single-mask overlay uses
(cx.slice(all, mi*n, n)). Read the whole buffer once with cx.read instead
of one synchronous read_plane per mask, then split it into per-mask Planes
on the CPU. Same bytes, but the pipeline stalls once instead of N times.
2026-10-12 13:18:21 +08:00
Fabio LussiandClaude Opus 5.5 7fe040f66a docs: pin the docs checker to a commit and its SHA-256
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:41 +02:00
Atmik Shetty 2d8fc776b2 LR-EDIT-COLOR-GRADING: Cmd/Ctrl-drag keeps the saturation, Alt/Option fine-adjusts the colour wheels (#1160)
The wheels had Shift (keep the hue, change saturation). Lightroom's other
two modifiers are now there too:

- Cmd/Ctrl-drag keeps the puck's saturation and changes only the hue, so it
  moves round its circle; the circle is drawn as the guide, as Shift draws
  the spoke. Shift wins when both are down (keeping both would freeze the
  puck).
- Alt/Option-drag fine-adjusts: the puck moves a tenth as far as the pointer
  from where it was grabbed (the slider's fine-adjust factor), counted from
  the press so no travel is lost to egui's drag threshold. It combines with
  Shift and Cmd/Ctrl.
- Pressing or releasing a modifier mid-drag re-grabs the puck as it is at
  that moment (WheelGrab); wheel_pick takes a WheelLock (Hue / Sat) and
  returns finite values for any input.
- A puck at the centre has no hue, so a Shift-drag from there now takes the
  angle where the pointer pressed, not where egui first reported a drag.
- The wheel has a hover tooltip naming the modifiers.

Shift-drag already kept the puck's own hue whenever its saturation was 1 or
more; shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs pins that.

Tests (tests_grading.rs) drive the wheel through ui.dragWidget with each
modifier and check the develop values and one undo step per drag, plus unit
tests of wheel_pick (lock, clamp, centre, NaN/inf) and WheelGrab.
2026-10-12 10:31:26 +05:30
Royce 1f45462529 fix(cli): --connect 7980 takes a bare port as loopback (#1178)
After `lightcraft --control 7980` was running, the natural next thing to type
was `lightcraft-cli run --connect 7980 ui.inspect`. The CLI's argument parser
only consumed the value if it contained a colon (it was looking for a full
`HOST:PORT`), so `7980` fell through and was parsed as a command id, producing
`{"error":"unknown command `7980`","ok":false}`. The default address was used
behind the scenes, but only by coincidence matched `127.0.0.1:7980`.

The parser now also consumes a token that is all digits (a bare port) and
formats it as `127.0.0.1:<port>`. Non-numeric, non-`HOST:PORT`-shaped tokens
(e.g. `ui.inspect` after `--connect`) keep falling through so the existing
`--connect ui.inspect` form (use the default address, then run ui.inspect)
still works.

Two parser sites have the same shape — the global `--connect` in `cli()`
and the per-step `--connect` in `run_step()` — and both are updated.

Test: connect_bare_port_is_treated_as_loopback_port. cargo test -p
lightcraft-cli: 21 passed, 0 failed. cargo clippy -p lightcraft-cli
--no-deps clean.
2026-10-12 12:36:35 +08:00
树洋 陈 537789cf1d Merge remote-tracking branch 'origin/main' into feature/show-all-mask-overlays
# Conflicts:
#	docs/parity-checklist.md
2026-10-12 12:20:21 +08:00
树洋 陈 6fd740ac2d Address maintainer review: free shortcut, derived tooltip, fmt, locales
- view.maskOverlayAll: Cmd+O clashed with library.open; rebind to Cmd+Shift+O
- Masking panel tooltip now derives the key label from the binding (platform
  aware: Cmd vs Ctrl) instead of hardcoding Ctrl+O; base string localized
- gpu/render.rs: rewrite the AllMasks alpha gather as a for-loop so it passes
  cargo fmt --check (max_width 150)
- docs/parity-checklist.md: add LR-MASK-OVERLAYALL row
- locales: add 'Show All Mask Overlays' and 'Show all overlays' to de, es, fr,
  ja, pt-br, ru, uk, zh-hans, zh-hant
2026-10-12 11:44:03 +08:00
Diego Ospina 6d9b1a64bf i18n(es): face recognition and AI Denoise in Spanish, style-guide fixes
Adds the 36 face-recognition entries Spanish lacked, translates 100 that
still held the English text (AI Denoise and its messages), and applies
docs/localization-es.md: infinitive instructions instead of tú, Upright
as «Vertical», Grab bag and Develop corrected.

AI-assisted, native review welcome.

Models: Opus 5.5, Terra 5.6, DeepSeek V4 Flash
2026-10-11 22:21:57 -05:00
Fabio LussiandClaude Opus 5.5 d3690e76c4 docs(user): apply final review fixes
Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 6: blank line in install.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:06:01 +02:00
radialmonster 81733ffd81 LR-IMP-FORMATS: bump RENDER_CACHE_VERSION for the DNG CFA phase fix 2026-10-11 22:59:24 -04:00
Fabio LussiandClaude Opus 5.5 675e661710 docs(user): apply final review fixes
Items B-LightCraft 1-5 and consistency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:49:21 +02:00
ChippedWandClaude Opus 5.5 17aafcd5bf LR-EXP-PSD: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy)
Hands each selected photo's original file itself (a raw stays a raw: no
render, no copy, nothing added to the library) to the editor set in
Settings ▸ External Editing ▸ Other editor, e.g. DxO PhotoLab. Also on a
photo's right-click menu (Edit In).

- `photo.openOriginalInExternal {ids?, app?}` (UI command, menus.rs): up to
  50 photos; demo photos and missing files are skipped with the reason; when
  none has a file it fails saying why (toast + error, no panic). Enabled when
  the active photo has a file and the host can start programs (not the web).
- Several photos go to one start of the editor (`Services::open_all_with`,
  editors.rs `open_all_with`): a single-window editor such as DxO PhotoLab
  drops the files that arrive while it is still starting when started once
  per file. The system default ("" editor) still opens them one by one.
- Settings hint, translations of the two new toasts, parity rows
  LR-EXP-PSD / MENU-FILE-PHOTOSHOP.

Tested by hand on Windows with DxO PhotoLab 8 (one and several raws).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:31:34 +02:00
Nigel Ihza Firdausy 3af5280ea6 Keep selection test independent of the visible list's mutable borrow 2026-10-12 09:27:55 +07:00
Nigel Ihza Firdausy 90a4088f10 fix(ui): make Nikon HE NEF codec licensing limitation explicit (#1172) 2026-10-12 09:27:54 +07:00
Nigel Ihza Firdausy 559d340d1c Fix preset Amount undoing unrelated edits or another photo 2026-10-12 09:27:29 +07:00
ChippedWandClaude Opus 5.5 9ae3b365a1 Windows / clippy 1.98: as_chunks in the spot fill, unix-only lock test helper
`cargo xtask ci` failed at clippy on Windows with Rust 1.98.1 before any
feature work:
- crates/pipeline/src/spots.rs: clippy::chunks_exact_to_as_chunks (new lint);
  `as_chunks::<3>()` is what crates/codecs already uses, same behaviour, no
  indexing.
- crates/catalog/src/lock.rs: `descriptor_copy` is only used by a
  `#[cfg(unix)]` test, so it was dead code in Windows test builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:26:35 +02:00
Nigel Ihza Firdausy 96085366f0 UI: translate Presets toolbar tooltips 2026-10-12 09:21:01 +07:00
Nigel Ihza Firdausy 2a837254b0 UI: translate develop tooltips and color control labels 2026-10-12 09:20:46 +07:00
Nigel Ihza Firdausy b05e6a6b86 UI: translate navigation icon tooltips 2026-10-12 09:20:36 +07:00
Nigel Ihza Firdausy a73382a5e2 UI: return translated Nikon codec notice as owned text 2026-10-12 09:18:38 +07:00
Nigel Ihza Firdausy fa7ee6ee9d UI: explain Nikon HE NEF codec limitation and conversion options (#1172) 2026-10-12 09:18:16 +07:00
Fabio LussiandClaude Opus 5.5 436c56a7ad docs: add user docs for LightCraft
User docs in docs/user/ (published at getartcraft.com/docs/lightcraft),
the docs-check and docs-publish workflows, and a "User docs" rule in AGENTS.md.
Modelled on the ArtCraft Launcher setup.

Sources per page:
- index.md: README.md, crafting-apps.ts (lightcraft entry), Cargo.toml (0.6.0)
- getting-started/install.md: README Downloads section, `gh release view` v0.6.0 asset list, craft-launcher catalog.rs and install docs
- guides/import-photos.md: crates/ui-egui/src/import.rs, panels/dialogs.rs, menubar.rs, menus.rs
- guides/cull-photos.md: docs/library-shortcuts.md, menubar.rs, panels/compare.rs, README
- guides/edit-a-photo.md: panels/edit.rs, develop/src/controls.rs, menus.rs, README
- guides/export-photos.md: panels/dialogs.rs (Export dialog), menus.rs
- guides/import-lightroom-catalog.md: docs/lightroom-catalog-import.md, menus.rs, issue #832
- troubleshooting/preview-only-raw.md: issue #662, widgets.rs, engine cmd/convert.rs, README
- troubleshooting/raw-photos-look-dull.md: issues #860, #778, #697
- troubleshooting/get-help.md: README (logs), menus.rs Help menu, issue #860
- faq/free-and-private.md: README (local-first, license), ROADMAP link
- faq/supported-files.md: README, menus.rs, export dialog

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:06:24 +02:00
sanyamsingh001andCodex 594362d717 GH-104: finish initial layout before offline frame timing
Finish the initial UI layout and assert font readiness before timing the existing views. Preserve the 300 ms limit and final probe assertions.

Co-authored-by: Codex <[email protected]>
2026-10-12 09:10:27 +10:00
dev-null-undefinedandClaude 536d6b88ac LR-VIEW-COMPARE: Compare and Reference panes say "Rendering…" as the loupe does
Compare and Reference View are where focus is checked at 1:1, and their panes show the same
stand-ins as the loupe: a thumbnail while the photo renders, the last render after an
edit, the magnified frame under a window not in yet. Each pane now shows the loupe's
"Rendering…" pill once that has lasted half a second with nothing new (widget
`compare:rendering:<pane>`), and its failed pill ("This photo can't be opened", or
"Original offline · no smart preview", the reason on hover) when the render failed behind
a thumbnail (`compare:renderFailed:<pane>`). Survey's small fit tiles don't.

Compare panes have no draft renders, so a slider drag on the photo would leave its pane
saying "Rendering…" for the whole drag: the photo being dragged doesn't count until the
drag ends. Each pane's wait belongs to the photo in it, so one that moves in (swap, Make
Select) starts its own. A retry of a failed render keeps the failed pill, as in the loupe.

Co-Authored-By: Claude <[email protected]>
2026-10-12 00:01:37 +02:00
dev-null-undefinedandClaude 31c31b726b LR-VIEW-DETAIL: the loupe says when the render behind a stand-in fails
A photo whose render failed while the loupe showed its embedded JPEG or a thumbnail kept
that stand-in up as if it were the photo; "This photo can't be opened" only showed when
nothing at all could be drawn. Now a caution pill at the "Rendering…" spot says so:
"Original offline · no smart preview" when that is why, else "This photo can't be opened",
with the reason on hover and for screen readers (widget `loupe:renderFailed`). Agents see
it as `ui.inspect` → `loupe.failed`, so one waiting for a finished loupe knows when there
won't be one.

A library change retries failed renders; while the retry of the same key runs, the pill
stays and "Rendering…" doesn't take over. The renderer remembers which photo a failure
belongs to (`failure_of`): the centre message no longer shows the previous photo's error
for a moment after a step. Tests share one render offload (`headless::RenderGate`) that
holds renders and can fail chosen slots.

Co-Authored-By: Claude <[email protected]>
2026-10-12 00:01:22 +02:00
radialmonster 67dc806ca9 LR-IMP-FORMATS: DNG CFAPattern is anchored at the ActiveArea, not the sensor (odd active-area offsets rendered magenta, #1086)
The DNG CFAPattern describes the top-left of the ActiveArea, but RawImage::cfa is sensor-anchored (the crop to the active area shifts it by the area's offset). The DNG reader now moves the tag's pattern out to the sensor origin, and the DNG writer states the pattern back at the ActiveArea. A DNG whose area starts on an odd row or column (the Canon EOS 60D DNG of #1086, top 51) no longer reads RGGB as GBRG.
2026-10-11 17:59:54 -04:00
dev-null-undefinedandClaude 89fa4e4b59 LR-IMP-FORMATS: a float raw with NaN or infinite samples no longer panics the demosaic
Cause: a floating-point CFA DNG is read as stored (no check of its float
samples). AHD and PPG clamp each green estimate between its two neighbouring
greens with `est.clamp(a.min(b), a.max(b))`; when both greens are NaN the
bounds are NaN and `f32::clamp` panics (ppg.rs:45, ahd.rs green pass). Any
float DNG with two adjacent NaN greens crashed decode + develop. Infinities
alone did not panic but turned into NaN in the demosaic arithmetic.

Fix, at two layers:
- demosaic: a `clamp_between` helper replaces the three clamps. With finite
  or infinite bounds it is the same `f32::clamp` call (bit-identical
  results); with NaN bounds it returns the estimate unclamped, so the panic
  is gone whatever puts NaN into the mosaic.
- DNG decode: non-finite float samples are replaced where the white level is
  known. NaN and -inf read as black (0), +inf as the white level (a clipped
  highlight). No sensor records NaN or infinity, so a valid file is never
  touched; finite samples, negative or above white included, stay as stored.

Tests: `demosaic::tests::non_finite_samples_do_not_panic` (NaN block, +/-inf
and an all-NaN mosaic through Bilinear, PPG and AHD; plus `clamp_between`
equal to the old clamp bit for bit over finite, signed-zero and infinite
values) and `non_finite_float_samples_decode_and_develop` (a float CFA DNG,
uncompressed and Deflate, with NaN and +/-inf samples: decoded values
checked, develop with all three methods gives a finite image). Both panicked
before the fix at ppg.rs:45 / ahd.rs.

Co-Authored-By: Claude <[email protected]>
2026-10-11 23:48:19 +02:00
Chad ElliottandClaude Opus 5.5 d79dad9ab1 docs(gaps): #1136 gap 26 notes indexed stack and album lookups at 100k
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:48:16 -05:00
Chad ElliottandClaude Opus 5.5 39c33a99d6 perf(ui): #1136 dialogs count once per library change, not every frame
Create Smart Album ran a full query for its count and Rename Keyword
scanned every photo on each frame the dialog was open; Merge Keywords
recomputed suggestions. They use Caches::dialog_count (keyed by catalog
revision and what is counted, and the minute for relative-date rules) and
the cached suggestions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:48:16 -05:00
Chad ElliottandClaude Opus 5.5 3f3ffa541a perf(engine): #1136 album order and album commands without per-photo scans
A manual album shown in its own order sorted with position() per photo
(O(n^2): 2.0 s for 20k photos, now 18 ms). album.addPhotos, removePhotos
and toggleTarget test membership with sets. The 100k scale test measures
all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:48:16 -05:00
Chad ElliottandClaude Opus 5.5 615d8a365e perf(catalog): #1136 index stacks and album membership, sort keys once per photo
stack_of, validate_stack and regular-album membership looked a photo up by
scanning every stack or the album's list, so an "In a Stack" or "Album"
smart album, or a query filtered to an album, cost photos x list length
(2.7 s and 0.6-0.8 s at 100k photos). Derived indexes (never saved) answer
them now: built on first use, kept current by the six ops that change
stacks or album photos in apply_inner, so undo, failed-batch rollback and
journal replay follow. A file with a photo in two stacks falls back to the
scan.

query() works out each sort key once (File Name was lower-cased twice per
comparison) and tests `only` as a set; smart-album passes prepare the
filter once; rules name flag, label and kind without formatting per photo.

A property test checks the indexes against a scan through random op
sequences, rollbacks, snapshots, replay, clones and undo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:48:16 -05:00
Chad ElliottandClaude Opus 5.5 dbf2dcbfbd docs: #1037 visible skips and the ci report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:33:54 -05:00
Chad ElliottandClaude Opus 5.5 82045f4d2c feat(xtask): #1037 ci reports the machine, GPU adapters and skipped tests
`cargo xtask ci` points each test step at target/ci/skips-<step>.jsonl,
then probes the machine: OS, CPUs, RAM, toolchain, corpus and craft-fonts,
and the GPU through the ignored `gpu_probe` test of lightcraft-gpu (run
right after the workspace tests with --workspace, so it reuses their build;
xtask stays without wgpu). The run ends with the skips grouped by need and
reason and writes target/ci/report.json (schema 1: commit, dirty flag,
machine, capabilities, step results and times, skips) and
target/ci/summary.md for a PR (`--summary-md` prints it). `--hardware`
also runs the lightcraft-gpu tests once per backend that has a hardware
adapter. Nothing is uploaded; reasons are cut free of the workspace root
and home directory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:33:54 -05:00
Chad ElliottandClaude Opus 5.5 3e42f2e05c feat(testkit): #1037 tests record why they skip
libtest has no skipped status and hides the output of passing tests, so a
test that returns early without a GPU, a corpus file, a craft-fonts face or
a model passed silently. `lightcraft-testkit` (the testkit slot the layering
table already reserves, dev-dependency only, std only) adds
`skip!(Need, "reason")`: it prints the note as before and, when
LIGHTCRAFT_SKIP_LOG names a file, appends one JSON line (need, reason, test,
binary, file:line). Plain `cargo test` is unchanged. The 61 existing skip
sites use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:33:54 -05:00
Chad ElliottandClaude Opus 5.5 929202b95f docs: #1046 document the cancelled flags and that the UI decides on error kinds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:04 -05:00
Chad ElliottandClaude Opus 5.5 b3fcb77922 fix(ui-egui): #1046 decide on error kinds and flags, not message text
LightcraftApp::run_typed keeps the engine's ErrorKind in a CommandError;
run() is run_typed(..).map_err(|e| e.message), so its callers are
untouched. The face detector offer and the SAM 3 download dialog decide
on ModelMissing, the SAM 3 download toast and the Apple Photos note on
the `cancelled` flag, and a Lightroom inspect/import on its own cancel
flag only, so a failure that mentions "cancelled" is still a failure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:04 -05:00
Chad ElliottandClaude Opus 5.5 e4c36749ef feat(engine): #1046 typed error kinds for cancelled work and missing models
EngineError gains Cancelled and ModelMissing { model: AiModel } and a
kind() -> ErrorKind, so frontends decide on what an error is rather than
on its words. The face detector, SAM 3 model checks and the Lightroom
job produce them at the source; the error text is unchanged, so control
channel and MCP clients see the same `error` strings.

Background results that cross the wire as JSON get an additive flag
instead: SAM 3 download status `cancelled`, Apple Photos import outcome
`cancelled: true` next to `skipped`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:04 -05:00
dev-null-undefinedandClaude 2df78c9e58 LR-BEHAV-BG-TASKS: a render job that panics fails instead of never answering
A panic inside RenderJob::run or QuickJob::run (a raw decoder or pipeline bug) unwound the
job pool's worker without sending a result. The slot stayed pending and its key was never
asked for again, so the loupe or the thumbnail rendered forever; and the pool never
replaces a dead worker, so after as many such panics as it has workers (2–6) no photo
rendered until LightCraft was restarted.

Both now catch the panic (guard::catch) and return it as the job's failure, answering the
request it came from, so the renderer records it like any failed render: no retry until
the catalog changes, and the workers live on. run() is the one place every render goes
through (the pool, export, Build Previews, contact sheets, face and AI-mask workers);
callers that had their own guard keep working, and the watermark preview's, now
redundant, is gone. The web build aborts on panic, so nothing changes there.

Co-Authored-By: Claude <[email protected]>
2026-10-11 23:23:50 +02:00
DennisandClaude Opus 5.5 4ddbb5a398 LR-EDIT-OPTICS-PROFILE: in-camera crop modes get the frame's crop factor, not the body's
A lensfun profile is scaled by the crop factor of the recorded frame. `lookup` took the body's from the
database whenever the camera was in it, so a full-frame body shooting a crop of its sensor (APS-C /
Super 35 mode, or switched there by an APS-C lens) had the profile applied as if the crop were the full
frame: the crop's corners got the full frame's corner distortion and vignetting. On a Sony ILCE-1M2 in
APS-C mode with the FE 100-400mm F4.5-5.6 GM OSS at 400 mm, the warp's r³ coefficient was the full
frame's 0.0215 instead of 0.0096.

The 35 mm equivalent focal length shows the crop: when it implies a crop factor more than 10 % above the
body's, that wins (`frame_crop_factor`); within 10 % the difference is the tag's whole-millimetre
rounding and the body's value stands. Without the body in the database the 35 mm equivalent is used, as
before.

Tests: `in_camera_crop_mode_uses_the_frames_crop_factor` (fails on main: in its profile the APS-C frame's
vignetting gain is 0.40 instead of 0.18) covers APS-C mode, full frame, a 35 mm equivalent rounded by a
millimetre, no body in the database and a hostile focal length. Checked on 19 private ILCE-1M2 ARWs
(FE 100-400mm GM, Sigma 24-70mm F2.8 DG DN | Art, full frame and APS-C, firmware 2.01-4.00): every
APS-C coefficient is the full-frame one scaled by 1/1.5².

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 22:50:54 +02:00
lloydandClaude Opus 5.5 090553d14d LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965)
Photo Merge decoded each raw itself and wrote the reference frame's own colour
tags into the merged DNG. A CR2 (and NEF, ARW and the other raws whose colour is
fitted from the camera JPEG) has none, so an HDR merge of CR2s rendered with no
camera colour: green and about 16 L* dark.

`lightcraft_merge::load_frame_with` takes the raw's colour tags from the caller,
and the engine passes `starting_colour_tags`: the colour the raw opens with
(`starting_colour`) as DNG tags. That is one forward matrix with a colour matrix
and AsShotNeutral that agree with it at the as-shot white, the look's hue/sat
table as ProfileHueSatMap and its tone curve as ProfileToneCurve. Raws with
their own matrices (DNG) keep their tags. The look's chroma curve has no DNG
tag and is left out.

On two CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies against the photo
alone: mean CIE76 dE 26.15 -> 1.07 and 22.89 -> 0.47.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 22:42:18 +02:00
Fabio LussiandClaude Opus 5.5 12498b3acf CLI --import <folder> uses the engine's import extensions (HEIC, HEIF, NRW)
expand_paths kept its own PHOTO_EXTENSIONS list, which had fallen behind
lightcraft_engine::import::EXTENSIONS. It now filters with
import::is_supported, so folder walks and library.import take the same files.

Fixes #1080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 22:13:57 +02:00
Robert WoldbergandClaude Opus 5.5 8ee7519a5f Merge branch 'main' into lightrroom-import-progress
Conflicts in crates/ui-egui/src/lightroom_import.rs: main moved the
Lightroom import's progress from its own window to the shared activity
stack, and this branch had added a second bar to that window.

Resolved by keeping main's activity row and feeding it this branch's
step-by-step progress (LightroomProgress): the row counts the step under
way (the catalog, reading the photos, adding them) and names it, so it
moves while the photos are read. The progress window stays removed; the
whole-import fraction is still in the task's status.

The engine half of this branch merged without conflict and is what main
still lacks: reading the photos is counted, and Cancel stops it.

main's two activity tests now drive the task through LightroomProgress.
"Reading photos…" and "Adding photos…" are added to the language
catalogs, cut from the existing "… N of M" translations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:12:26 -06:00
Matthew James SivakandClaude Opus 5.5 6797c6b483 fix: share the sidebar's off-screen row skip, keep widths stable (#1011)
Review follow-up on the keyword sidebar fix:
- The skip is a shared helper, `out_of_sight`, that Keywords and Folders
  call before a row's work; a row out of sight still takes its room, is
  listed for the control channel (agents and tests find it as before) and
  asks for its measured width, so the content no longer widens and
  narrows as rows scroll by.
- The scroll to a keyword made from the header is a per-frame request
  for any row id (`SCROLL_TO_ROW`), set before the sections are drawn and
  cleared after: no frame stamp, nothing left behind.
- keyword_rows/folder_rows are skip-or-draw then recurse (the drawn row
  is keyword_row/folder_row); leaves no longer look up an open state.
- keyword_tree builds children lists as levels are made and sorts by the
  lower-case name it already computed; empty_hint uses ROW_HEIGHT; the
  test reuses tests_panels::scroll_sidebar.

58,000 photos / 4,000 keywords, release: 1.6 ms per unchanged frame
(7.4 ms before the fix), keyword_tree 14 ms (156 ms).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 22:11:02 +02:00
Matthew James SivakandClaude Opus 5.5 aa7e0bbc34 fix: left panel slow with a large keyword list (#1011)
Cause: the sidebar's Keywords section drew every keyword row on every
frame (layout, text measuring, widget registration, hover text, context
menu), even the thousands out of sight, and every catalog edit rebuilt
the keyword tree from all photos with several String allocations per
keyword level. On a 58,000-photo library with 4,000 keywords an
unchanged frame took 7.4 ms in the sidebar alone and the frame after a
rating 220 ms (156 ms of it the tree), release build on Apple Silicon.

Fix:
- Keywords rows out of sight only take their height (open children are
  still walked, so the layout is unchanged), as the grid already does
  (#35). A keyword made from the header's + is always drawn and the
  sidebar scrolls to it, so it is in sight even at the end of a long list.
- Catalog::keyword_tree works out each distinct keyword string's levels
  once and then only counts (a per-level "last photo" stamp replaces
  the per-photo HashSet of Strings).

Now: 0.55 ms per unchanged frame (7 rows drawn instead of 4,000),
keyword_tree 17 ms, the frame after a rating 44 ms.

Test: tests_keyword_sidebar::keywords_section_draws_only_the_rows_near_
the_screen (failed before: "2000 of 2000 keyword rows drawn"; now the
rows near the screen, and scrolling reaches the last keyword), an
ignored timing probe at the report's library size, a catalog test for a
keyword repeated on one photo, and the_keywords_header_creates_a_keyword
now checks the new keyword is in sight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 21:54:42 +02:00
s17sh70x2jfl031ag 465601b76c HEIF: move the heic-rs pin from 0.1.1 to 0.1.4
heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing:
four in release builds (a uuid box too small for its extended type, a
block starting right of the picture, a conformance window larger than the
picture, and a reference row below the picture) and two with overflow
checks on (a PCM bit depth above the sample bit depth, a slice QP out of
range). The API, MSRV and output are unchanged.

Mirrors the same change in PhotoCraft's photocraft-heif.
2026-10-11 12:46:22 -07:00
Andrew FrahnandClaude Opus 5.5 8fbc53584f L10N: fill the de, es, fr, ja and uk catalogs (gap 21)
Every message another catalog knows is now in the German, Spanish, French,
Japanese and Ukrainian catalogs (plain and formats): 927 plain and 34 format
messages added or translated, following each catalog's existing
terminology, register and punctuation; placeholders unchanged. Menu paths
inside translated text use → (the UI font has no ▸). Values identical to
English are kept only where correct (ISO, Display P3, words spelled the same,
language self-names, placeholder-only formats).

Measured: de 97%, es 98%, fr 97%, ja 99%, uk 99% of 2,398 catalog messages.
pt-BR, ru, zh-hans and zh-hant are left to their open PRs. Agent-made
translations: native-speaker review welcome.

Also fixes the Rust 1.99 clippy lint chunks_exact_to_as_chunks in
pipeline/spots.rs that fails `cargo xtask ci` on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:45:46 +11:00
Andrew FrahnandClaude Opus 5.5 b7d77f16b8 LR-IMP-CAMERA-COVERAGE: verify 17 uncompressed Olympus and Samsung models on CC0 files
One CC0 raw.pixls.us file per uncompressed Olympus ORF and Samsung SRW
body not yet pinned (23 files, 17 Adobe models) is added to RAW_SAMPLES
and pinned in crates/raw/tests/olympus_samsung_corpus.rs: own sensor
hash plus a black-box check against the camera's embedded JPEG (the
E-10 pinned only, inspected by eye).

Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint.

Docs: evidence.tsv + coverage re-measure (252 verified), gap 10,
checklist, raw-parity, ROADMAP. Headline unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:38:36 +11:00
Wladmir Júnior b7e39afe79 Merge remote-tracking branch 'origin/main' into i18n/pt-br-faces-and-sorting 2026-10-11 16:32:11 -03:00
Andrew FrahnandClaude Opus 5.5 9181aa0b6c LR-IMP-FORMATS: import and decode Mamiya MEF (uncompressed 12-bit; WB from the RGB preview)
Sources: TIFF 6.0 and own black-box analysis of the CC0 ZD sample.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:27:05 +11:00
Matthew James SivakandClaude Opus 5.5 ed737afda0 fix: a folder listed twice in Local can't be collapsed (#1093)
Local lists a folder once per place it is in: Pictures as a place of its
own and again inside Home. Each Local row keyed its open state and its
triangle's egui id by the folder's path alone, so once Home was open the
two Pictures triangles shared one id: the copy drawn last took the clicks
of the copy drawn first, and the first Pictures could not be collapsed.
The open state was shared too, so both copies opened and closed together.

The tree now keys both by the top-level place it hangs from as well, so
every row opens and closes by itself. Regression test: two Local places,
one inside the other, and a click on each copy's triangle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 21:17:12 +02:00
Andrew FrahnandClaude Opus 5.5 f63522a234 LR-IMP-CAMERA-COVERAGE: verify 21 Sony and Leica models on CC0 files
One CC0 raw.pixls.us file per Sony and Leica body that no earlier bulk
check covered (22 files, 21 Adobe models) is added to RAW_SAMPLES and
pinned in crates/raw/tests/sony_leica_corpus.rs: own sensor hash plus a
black-box check against the camera's embedded JPEG where it has a colour
one (five files pinned only, inspected by eye).

Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint.

Docs: evidence.tsv + coverage re-measure (256 verified), gap 10,
checklist, raw-parity, ROADMAP. Headline unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:05:11 +11:00
neronek22 b764715dc5 Remove accidentally duplicated localization files 2026-10-11 21:00:58 +02:00
Andrew FrahnandClaude Opus 5.5 b538b23d40 Gap 27: CPU-GPU equivalence verified on an AMD Radeon 8060S (Vulkan, Linux)
First non-Apple GPU verification: 16 tests / 135 cases pass, 124 at
max 1 LSB, 10 bit-identical, one at max 2 LSB (0.0002% of pixels), worst
mean 0.0003 LSB. Recorded in gpu-pipeline.md, hardware-parity.md and
gaps.md. Also as_chunks in spots.rs for the newer clippy lint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:52:43 +11:00
Andrew FrahnandClaude Opus 5.5 a6d048be8d LR-IMP-FORMATS: decode Epson ERF (12-bit 16-byte blocks; WB from the preview's neutrals)
Sources: TIFF 6.0 and own black-box analysis of the three CC0 samples;
white-balance estimate from the embedded preview: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:43:30 +11:00
neronek22 08e692f7c9 Add complete Polish (pl) localization 2026-10-11 20:38:21 +02:00
Nigel Ihza Firdausy 83e6108346 fix(i18n): translate library context and zoom menu labels 2026-10-12 01:35:45 +07:00
Nigel Ihza Firdausy b453f587be feat(curve): overlay the current histogram behind tone curves 2026-10-12 01:33:55 +07:00
Nigel Ihza Firdausy 6cde09f38f fix(i18n): translate masking context menu actions 2026-10-12 01:31:18 +07:00
Andrew FrahnandClaude Opus 5.5 bbd2e8cbd4 LR-IMP-CAMERA-COVERAGE: verify 97 Canon CR2, Pentax and Ricoh models on CC0 files
One CC0 raw.pixls.us file per Canon CR2, Pentax and Ricoh body that
decodes (100 files, 97 Adobe models) is added to RAW_SAMPLES and pinned
in crates/raw/tests/canon_pentax_corpus.rs: own sensor hash plus a
black-box check that the decoded green follows the camera's embedded
JPEG (r 0.69-1.00; four files pinned only, inspected by eye).

Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint.

Docs: evidence.tsv + coverage re-measure (332 verified, 50% on verified
bodies), gap 10, checklist, raw-parity, ROADMAP. Headline unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:27:26 +11:00
Nigel Ihza Firdausy ba45333986 feat(shortcuts): support Lightroom Classic F6 and F7 panel toggles 2026-10-12 01:17:30 +07:00
Bobby JoseandClaude 1397c4b42a LR-IMP-FORMATS: shorten the ColorData version 66 comments
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_017JKdR4oZ3jRSPPwMLLTa2U
2026-10-11 14:16:02 -04:00
Nigel Ihza Firdausy fdccb413cd fix(import): avoid scanning symlink aliases into the library 2026-10-12 01:15:34 +07:00
Bobby JoseandClaude 73098bf5a3 LR-IMP-FORMATS: EOS R6 Mark III as-shot white balance (ColorData version 66)
Version 66 keeps version 64's as-shot white balance and black level
offsets; its word 661 is below where the sensor clips, so the white
level stays measured from the data. Checked on 33 R6 Mark III files
from 12 shoots: all now open with their as-shot white balance, black
levels match the masked columns.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_017JKdR4oZ3jRSPPwMLLTa2U
2026-10-11 14:13:06 -04:00
Andrew FrahnandClaude Opus 5.5 2c3d7a743f Control channel: give up a request the app draws no frames for, never run it late (#718)
On Wayland a window on a hidden workspace or minimized gets no frame
callbacks, so queued control requests waited 60 s and then ran when the
window came back, doubling retried work. The server now abandons a request
the app hasn't started after 5 s without a frame, says why, and the app
skips abandoned requests. Running requests keep the full 60 s. Also
as_chunks in spots.rs for the newer clippy lint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:13:02 +11:00
Nigel Ihza Firdausy 87be53279b fix(preview): account for replaced thumbnail cache entries 2026-10-12 01:10:31 +07:00
OneSlipsandClaude Opus 5.5 bb925f6d2e LR-MASK-EFFECTS: Effect menu, New Effect Preset dialog and Manage window
The Effect menu above the mask sliders is grouped (★ Favorites first, then
groups in menu order), searchable, scrolls, works with ↑ ↓ Return, marks the
matching effect (else Custom) and previews an effect on the photo while the
pointer rests on it. Right-click: apply to all masks or to the same-named mask
on the selected photos, update, rename, duplicate, star, move to group, delete
(asks first). Reset Sliders; Update "<last effect>" with Current Settings.

New Effect Preset dialog: name, group, which sliders (All / None / Changed
Only), Amount, brush size/feather/flow. Manage Effect Presets window: search,
inline rename, update with mask, duplicate, stars, groups, export, delete with
confirmation. Names padded with runs of spaces (as LrC users do) are shown
collapsed. Import toasts name the groups and the settings kept but not applied.
New messages translated in every catalog. Docs: parity row and xmp-interop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 03:57:07 +10:00
robmdunnandClaude Opus 5.5 6178021107 LR-IMP-CAMERA-COVERAGE: measured image area for Nikon bodies without CropArea
Nikon bodies from before ~2016 record no CropArea, so the decoder kept
their whole sensor data, less trailing dark columns. Where that data
holds masked lines above black or optical black, they were developed as
a coloured or dark bar on that edge: the D7000 and D5100's 36 columns at
~600 (#976), the D800's 14 (#914, the top in portrait), and others.

MEASURED_BODIES (crates/raw/src/vendor/nef.rs) now gives 36 model /
data size / depth groups the frame of their camera JPEG inside the data:
D7000, D5100, D800, D800E, Df, the D4's 12-bit mode, D3, D3S, D700,
D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, D60,
D80 and D2Xs. An entry matches model, data size and depth exactly; the
measured white level becomes optional (only the D4's 14-bit mode has
one). Bodies whose data holds only picture beyond the camera frame get
no entry and render as before.

`cargo xtask raw-frame FILE...` (crates/engine/examples/raw_frame.rs)
measures a body: it aligns each raw's embedded camera JPEG with the
sensor data (half-size luminance, gradient correlation, sub-pixel peak;
weak, ambiguous or between-pixel peaks are flagged), counts the edge
lines that hold no picture and reports what the decoder's crop keeps
beyond the camera frame. It prints entries only with evidence: at least
3 clean files agreeing exactly, of the model (12- and 14-bit pooled) or,
with at least one of its own, with sibling models of the same readout
(data and JPEG size) that agree on the same frame. On the D7500, which
records its CropArea, it finds that area exactly; it declines the Z f,
whose JPEG is distortion-corrected.
It works for any make whose raw embeds a full-size camera JPEG: on
Sony, Canon CR2 / CR3, Pentax and Samsung files it finds the decoder's
own crop (relative to the active area).

docs/nikon-bodies.md surveys every Nikon raw on raw.pixls.us (98
models, 291 files; 138 CC0 samples measured): 22 models record
CropArea; of the 88 groups without it, 36 have entries, 13 are fine, 25
could not be aligned and 7 do not decode. The D3200, D3100, D200, D50,
D750 and Nikon 1 V1 / V2 are pending, each listed with the files it
needs; the doc also describes how to add a body. raw-parity.md no
longer claims every NEF coding but HE decodes.
measured_bodies_are_consistent checks every entry lies inside its data
and is listed once.

The evidence is committed in docs/nikon-bodies/: headers.json (every
file's raw size, depth and maker-note crop tags), samples.json (the 138
measured CC0 files with URL and sha256) and frames.jsonl (the tool's
per-file results). `cargo xtask corpus --download nikon` fetches the
samples (sha256 checked) so `cargo xtask raw-frame` reproduces every
count.

Fixes #976
Fixes #914

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 13:56:42 -04:00
OneSlipsandClaude Opus 5.5 482dcacd37 UI: targeted adjustment says how to leave it; a wheel notch scrolls 100 points
A Color Mixer Hue/Saturation/Luminance (or curve) targeted-adjustment tool
left on takes over every click and drag on the photo, so click-to-zoom and
panning stopped working and the canvas looked frozen (user report). Esc
already turned it off, but nothing said the tool was on: the mixer's
buttons look like tabs. Turning one on now shows a toast: drag up or down
on the photo, Esc to stop.

A mouse wheel notch scrolled egui's default 40 points; an open Edit panel
is 2000+ points tall. Lines now scroll 100 points (theme::WHEEL_LINE_POINTS,
set in apply_kind so every theme has it), about Lightroom's step; trackpads
report points and are unaffected. What assumed a 40-point notch keeps its
per-notch behaviour: wheel zoom (Ctrl+wheel and the loupe's plain wheel)
uses theme::WHEEL_ZOOM_PER_POINT so a notch still zooms ~1.22x, and a
focused slider's wheel nudge counts one notch (WHEEL_LINE_POINTS) per step.

Tests: the tool announces itself and Esc leaves it (view unchanged); three
wheel notches scroll at least 300 points (120 before); every theme sets the
notch and keeps the zoom per notch; wheel nudges in notch units; the
slider wheel-nudge test lets egui's smoothing deliver its 120 points.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 03:55:08 +10:00
Andrew FrahnandClaude Opus 5.5 8695c3391f LR-PREF-DISPLAY: on Linux, previews use the display profile from colord
While no monitor profile is chosen, the Linux app reads the profile the
desktop assigns to the display in colord (org.freedesktop.ColorManager,
system bus, via zbus) on a background thread every 3 s, and shows
previews through it. With several displays it is used only when they all
share one profile; without colord the view stays sRGB. Windows' WCS
needs FFI and stays manual. Also satisfies clippy 1.99's
chunks_exact_to_as_chunks in spots.rs.

Sources: the colord D-Bus API documentation; own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:42:12 +11:00
Andrew FrahnandClaude Opus 5.5 a1d7c488f8 LR-EXP-RENDERDNG: Render to DNG export format (linear float DNG of the rendered photo)
New export format `rendereddng` (export dialog: Format DNG, "DNG of: Rendered
photo"; `app.export`; MCP export tool): the rendered photo, with the edit,
crop, size, output sharpening and watermark baked in, written by the new
`lightcraft_raw::write_rendered_dng` as a LinearRaw DNG of 16-bit float Deflate
tiles in the export colour space. ColorMatrix1 (XYZ -> space), ForwardMatrix1
(space -> XYZ D50), a neutral AsShotNeutral and an identity ProfileToneCurve
make raw converters show it as rendered. HDR edits keep highlights above white.
Bad input (wrong sample count, zero or huge sizes, NaN/inf, out of half-float
range) gives an error or is clamped, never a panic.

Also fixes the Rust 1.99 clippy lint chunks_exact_to_as_chunks in
pipeline/spots.rs that fails `cargo xtask ci` on main.

Sources: Adobe DNG Specification 1.4+ (LinearRaw, ColorMatrix/ForwardMatrix,
ProfileToneCurve); own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:33:12 +11:00
Andrew FrahnandClaude Opus 5.5 a44a4baa31 LR-IMP-CAMERA-COVERAGE: verify 117 Nikon and Fujifilm bodies on CC0 files
One CC0 raw.pixls.us file per Nikon and Fujifilm body that decodes is
added to RAW_SAMPLES and pinned in crates/raw/tests/body_corpus.rs: own
sensor hash plus a black-box check that the decoded green follows the
camera's embedded JPEG (r 0.77-1.00).

The sweep found COOLPIX 5700 / 8400 / 8800 decoding wrongly (two stacked
half-height fields, CMYG read as RGB): recorded as unsupported. #702 is
fixed, so the A100 evidence row is preview, not unsupported.

Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint.

Docs: evidence.tsv + coverage re-measure (352 verified, 86% use-weighted,
58% on verified bodies), gap 10, checklist, raw-parity, target-app-parity,
ROADMAP. Headline numbers unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:28:43 +11:00
Andrew FrahnandClaude Opus 5.5 c3be1ceead LR-BEHAV-ACCESS: canvas tools by keyboard; name hand-drawn widgets; Tab order test
canvas_keys.rs gives every handle on the photo a focusable stand-in with
an AccessKit name and position: crop corners, edges and box (crop.drag),
spot targets and sources plus a New spot point (Return adds), radial and
linear gradient handles and mask pins. Arrows move by 0.5% (Shift 5%),
one undo step per press; the stand-in keeps arrows/Return/Space from the
shortcuts. The audit now counts any focusable or clickable node; ~50
hand-drawn icons, tiles and rows are announced as buttons. A test walks
Tab through the Edit/Crop/Remove/Masking panels top to bottom.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:13:49 +11:00
Andrew FrahnandClaude Opus 5.5 32819d4900 LR-MASK-BRUSH: pen pressure scales brush size and flow
Brush strokes record a pen pressure (0..1) per point from egui touch
force while Brush > Pen Pressure is on (default). Each dab's radius goes
from a tenth to all of the brush, and its flow is proportional to the
pressure, in the committed stroke and the live preview alike. A mouse
stroke stores no pressure, so documents and renders are unchanged. The
GPU mask kernel leaves pressure strokes to the CPU. mask.brushStroke takes
`pressure`; bad lists are refused. Also satisfies clippy 1.99's
chunks_exact_to_as_chunks in spots.rs.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:04:01 +11:00
goujichengandDeepSeek V3.2 103089535a LR-IMP-PREVIEW-READ: the corpus test asks preview_plan for every file
`preview_plan`'s assertions sat inside the `header_plan == Ready` branch, so a NEF, an ORF or a PEF
— whose header probe reads samples and whose header plan is therefore `Whole` — never had its
preview plan looked at, and neither did any file the header plan declines. The display path wants
exactly that answer, so both plans are now asked for every corpus file, and the preview the plan
hands on must be the whole file's with the skipped bytes `0` and again `0xFF`; `Whole` stays a
legitimate answer (that format reads whole for a preview too) and is counted. The test prints what
each format reads for both plans.

Co-Authored-By: DeepSeek V3.2 <[email protected]>
2026-10-12 00:57:30 +08:00
goujichengandDeepSeek V3.2 3c7f450137 LR-IMP-PREVIEW-READ: a CR3 or a TIFF raw's preview is read from the plan's ranges
The header-only path added in #755 answers for a Fuji RAF, whose preview pointer sits in the first
bytes. A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can
keep its preview tag in an IFD the prefix cuts short: both fell back to `std::fs::read` of the whole
file — tens of megabytes for a JPEG of a few, on the format of the largest bucket in gaps.md (Canon,
33 % of photographers).

`preview_window` now asks `lightcraft_raw::sparse::preview_plan` — the module #961 added for the
import path, which had no production caller — which ranges `embedded_preview` reads, and reads
exactly those: `planned_preview` fills a buffer of the file's length, reads the ranges the plan names
and asks again until it says `Ready`. The buffer is the file's length, which the memory gate counted
already, and its untouched pages stay zero, so what this path costs is the bytes read — on macOS a
freed buffer of that size may sit in the allocator's cache rather than go back to the OS (measured on
a 38k-photo import, #832). The loop gives up past 60 % of the file, the bound step 2's partial read
uses, and a plan that does not fit, a file past 1 GiB or a window that does not decode all end in the
whole-file fallback.

The quick path hands the JPEG to the decoder alone, so two container facts the whole-file path applies
have to be visible in the bytes it is handed, or the file declines and the whole file keeps the
preview:

- a preview colour space of Adobe RGB (`decode_raw_preview` applies it; a JPEG-only decode has the
  JPEG's own space, else sRGB). A container that says sRGB does not decline — that is what the decode
  assumes anyway, and declining it took the fast path away from a real Sony ARW;
- the file's own EXIF orientation, when the preview JPEG carries none of its own: the plan's header is
  a bounded 64 KiB (the header route grows its prefix to the preview's declared extent instead), and
  an orientation tag it cannot see would show the preview unrotated.

Measured over the whole CC0 corpus (111 raws): 52 files take this route, reading a median 7.8 % of
their bytes (0.9-27.7 %) — RW2 3.3 %, Leica RWL 4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % —
while the 21 RAFs stay on the header route (median 4.5 %). The other 38 read whole: NEF, NRW, ORF, PEF
and SRW because the planner does not describe them (an SRW plans to the whole file, which the 60 %
budget then declines), plus the ARWs, CR2s, CR3s and DNGs that declare Adobe RGB or whose plan wants
more than that budget — the bytes they read are the ones the plan would have read anyway. Across all
111 files the plan handed on the same preview as the whole file, with the skipped bytes zeroed and
again 0xFF. `LIGHTCRAFT_PROFILE=1` prints

    [profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441)

Tests: a synthetic TIFF/DNG whose preview only its tags locate (identical pixels to the whole-file
path, the CFA strip unread), the same structure without the tag that makes it a raw, a file whose
orientation only the whole file shows, and a file that states Adobe RGB against one that states sRGB;
the opt-in real-file test reports which path answered.

Co-Authored-By: DeepSeek V3.2 <[email protected]>
2026-10-12 00:57:30 +08:00
dev-null-undefinedandClaude 6fc33e1ee1 LR-VIEW-DETAIL: the loupe says "Rendering…" while what it shows is not the render yet
A raw's embedded JPEG, a thumbnail, the previous render after an edit or the magnified
frame under a 1:1 window all look like the finished photo. Once a render has been on its
way for 0.5 s with nothing new on screen, a pill at the bottom of the photo (widget
`loupe:rendering`) says so; above the Navigator when that is in the way.

The wait starts when the render starts coming (an idle app draws no frames), so a quick
render never shows it, nor does a drag whose drafts keep up; once up it stays until
nothing is coming, so slow drafts don't make it blink. It watches what is on screen: the
After or the Before, and the windows in use. Not an activity row: it says the pixels
aren't final (docs/background-tasks.md).

Co-Authored-By: Claude <[email protected]>
2026-10-11 18:50:44 +02:00
Andrew FrahnandClaude Opus 5.5 1477f892b5 LR-IMP-FORMATS: verify 22 more CR3 bodies; EOS R6 Mark III white balance
All 110 CC0 CR3s on raw.pixls.us decode from sensor data; none uses a
refused CRX variant. One CC0 file per further body (1D X III, 90D, 250D,
850D, M200, M50 II, M6 II, R, R3, R5, R6, R6 II, R6 III, R7, R10, RP, R50,
R50 V, G5 X II, G7 X III, SX70, V1) is added to RAW_SAMPLES and pinned in
cr3_corpus.rs, checked against the camera's embedded JPEG.

The R6 Mark III writes ColorData version 66, laid out as 64/65; it lost
its as-shot WB and white level. Render cache 61 -> 62.

Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint.

Docs: evidence.tsv + coverage re-measure (257 verified, 92% use-weighted),
gaps 2/10, checklist rows, raw-parity, target-app-parity, ROADMAP, README.

Sources: own black-box analysis of CC0 files; ExifTool Canon tag tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 03:45:14 +11:00
kelannaandClaude Opus 5.5 b28ce8ef15 LR-PREF-DISPLAY: zoomed views stay sharp with a monitor profile
With a display profile set, the 1:1 (and closer) Detail and Compare views
showed only the whole-frame render magnified, not the sharp window cut
from the photo's own pixels. The renderer gives a display-profile render
its own key (RenderJob::with_display), but the window tiles were recorded
under the key the job had before, so draw_window never found the texture.
The tiles are now keyed with the job as the renderer runs it
(Renderer::job_for), in Detail and in Compare.

Tests: a_display_profile_keeps_the_sharp_window_at_one_to_one and
a_display_profile_keeps_the_compare_windows_at_one_to_one check that the
window is painted over the whole-frame render; both failed before the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEuxxPyG8t6KcADbZSEAUj
2026-10-11 09:39:40 -07:00
Andrew FrahnandClaude Opus 5.5 c78b457c0d LR-VIEW-ZOOM: soft proofing at 1:1 renders a zoom window; 1:1 pixels tested equal to export
Soft proof is per-pixel, so the After window carries the proof like the
whole-frame render under it (the proof key is part of the tile's look).
New tests: a 1:1 window of a 24 MP photo equals the full-size export to
within one level; soft proofing at 1:1 is a canvas-sized frame plus a
native window. profile_slider_drag gains a soft-proof case. Gap 4 closed,
checklist row to done with measured Linux latencies.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 03:37:35 +11:00
Andrew FrahnandClaude Opus 5.5 76f12dcabd Issue #989: fill_box reads its buffer with as_chunks::<3>() (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 03:37:35 +11:00
OneSlipsandClaude Opus 5.5 0f40a2d2f5 LR-MASK-EFFECTS: manage, group and fully import local adjustment presets (engine)
Mask effects gain groups, stars, partial presets (only some sliders, with or
without Amount), optional brush settings and the settings an imported preset
carries that we can't apply (kept for export). New commands: mask.updateEffect,
renameEffect, duplicateEffect, favoriteEffect, setEffectGroup,
renameEffectGroup, toolEffect; applyEffect takes several masks, all masks, or
the same-named masks on the selected photos in one undo step. A new mask starts
with the effect last applied to a mask of its kind (kept with the library).

Import (mask.importEffects and preset.import): Lightroom Classic local
.lrtemplate, Camera Raw local-correction .xmp, .lceffect and zips of these;
folders and LrC divider presets ("10 ----- DETAIL ----- 10") become groups;
grain / Point Color / toningLuminance reported and kept. Malformed files are
errors, never panics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:33:25 +10:00
OneSlipsandClaude Opus 5.5 594c046d52 LR-MASK-EFFECTS: mask effect (brush) presets and Lightroom local preset import
Named sets of local adjustment sliders, picked from a new Effect menu
above the mask sliders in the Masking panel (apply sets every slider and
keeps Amount; save current; import; right-click delete; the menu names
the matching effect, else Custom). Saved with the library preferences.

Commands: mask.effects, mask.applyEffect, mask.saveEffect,
mask.deleteEffect, mask.importEffects, mask.exportEffects (.lceffect).

Lightroom local adjustment presets (.lrtemplate with type
LocalizedAdjustmentPreset) are read by mask.importEffects and routed
there by preset.import / File > Import Profiles & Presets, which used to
reject them as having no develop settings. Their lower-camel Local* keys
map through the same table as XMP mask fields (crs_masks::local_values);
sliders without a counterpart (toningLuminance, grain) are reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:32:37 +10:00
OneSlipsandClaude Opus 5.5 b7222213c9 PERF-PRESETS: thousands of presets no longer stall every command and frame
A Lightroom collection imported whole (11 113 presets, a 42 MB presets.json)
made every interaction take seconds:

- Session::persist ran after every command and serialized the whole preset
  list to compare it with the last write: 609 ms per slider tick (release,
  the real collection). Session::presets is now a PresetList whose generation
  changes on mutable access; persist serializes only when it (or the profile
  lists) changed, and skips the write when the bytes hash the same.
  develop.set: 609 ms -> 2.3 ms; preset.apply: 598 ms -> 3.1 ms.
- The Presets panel deep-cloned every preset into a BTreeMap, built a row,
  widget id and (with thumbnails) a variant render job for each of them every
  frame. Grouping is cached per generation and only rows in view are laid
  out: 5041 -> ~20 rows a frame, 2.3 ms with 5000 presets.
- The preset pickers (Import dialog, Settings > Import) build only the rows
  in view.
- add_presets and opening a library compared every preset with every other
  (quadratic); both use hash indexes now. Opening no longer serializes the
  list once just to remember it.

Tests: develop commands don't re-serialize unchanged presets (and a change
is still saved), generation semantics, a 20 000-preset import, and the
panel building only visible rows (fails on the old code: 5041 rows).
An ignored bench (presets_scale_bench, LIGHTCRAFT_BENCH_PRESETS) times a
real presets.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:32:29 +10:00
OneSlipsandClaude Opus 5.5 6c79be9130 Windows: desktop entry paths, toolchain lints
desktop_exec treats a freedesktop Exec path as absolute when it starts
with '/' on every platform (Path::is_absolute wants a drive letter on
Windows); fill_box uses as_chunks (clippy::chunks_exact_to_as_chunks);
LibraryLock::descriptor_copy is cfg(all(test, unix)) like its only test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:32:08 +10:00
OneSlipsandClaude Opus 5.5 2da4b36b45 Windows: keep sidecar spellings on case-insensitive volumes; fix rename/sidecar tests
20 engine tests failed on Windows on clean main. Three real bugs on
case-insensitive volumes (Windows, default macOS APFS) caused most of them:

- Renaming a photo probed `a.XMP` before `a.xmp`. On a case-insensitive
  volume the upper-case spelling "exists" when only `a.xmp` is there, so the
  sidecar was moved as `Trip.XMP` (and its retained-packet record was taken
  under the wrong spelling). Upper-case candidates are now used only when the
  folder lists them exactly (new `MoveFs::listed`).
- `RealFs::same_file` on Windows (no file id in std) depended on argument
  order: `same_file("Trip.XMP", "Trip.xmp")` was false when `Trip.xmp` is the
  real entry, so saving after a partial rename reported a phantom collision.
  It is now symmetric, and also requires the names to differ only by case
  (two different files with equal size and mtime were "the same").
- Saving metadata picked the sidecar spelling by `is_file()`, lower case
  first, so the atomic write renamed an existing `Trip.XMP` to `Trip.xmp`.
  The spelling the folder lists now wins.

The rest were fixtures: FakeFs fault paths, catalog paths and expected
paths spelled with `/` while the code joins with the host separator.

Regression tests: case_insensitive_renames_keep_each_sidecars_spelling
(FakeFs, every platform; fails without the fix) and
same_file_spellings_are_symmetric_on_windows. Linux behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:31:53 +10:00
Andrew FrahnandClaude Opus 5.5 530320a6bd LR-BEHAV-ACCESS: AccessKit tree audit test; name fields, give sliders range
tests/accessibility.rs walks the AccessKit tree headless in every view,
Develop tool and 13 dialogs and fails on unnamed controls, sliders without
value/range and untitled dialogs. Fixes what it found: text fields are
named by caption or hint, develop sliders expose min/max/step/value text
and take Increment/Decrement, Tab-focused sliders take the arrow keys,
Auto Stack's slider is named. Also as_chunks in spots.rs for the newer
clippy lint. Docs: ui-parity audit section, checklist, gaps, ROADMAP.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 03:18:48 +11:00
THEtheChadandClaude Opus 5.5 a61a52ed8b perf(gpu): LR-BEHAV-GPU: horizontal box blur reads adjacent pixels
The horizontal box pass gave each thread 16+ consecutive pixels to slide
a running sum over, so a workgroup's threads read pixels 16 apart. On a
Radeon 780M (DX12) that pass took 9-15x as long as the vertical one, and
it sits under every GPU Gaussian and guided filter (NR, texture, dehaze,
clarity/base). Each thread now sums one pixel's 2r+1 window: adjacent
threads read adjacent pixels. Same 64x4 workgroups, parameters and
bindings; box_v unchanged.

Measured on the Radeon 780M (6000x4000): guided filter at sigma 1.75
828 -> 146 ms, colour-NR Gaussian 495 -> 104 ms. Full-size heavy-edit
export of the CC0 Sony ARW: GPU 3.6-4.0 s -> 1.9-2.0 s (CPU 1.9-2.1 s);
GPU-vs-CPU output distance unchanged.

Adds gaussian_matches_cpu (GPU blur vs lightcraft_raster::blur::gaussian,
1-3 channels, partial workgroups, windows wider than the image) and
per-pass rows to bench_kernels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:04:27 +00:00
Andrew FrahnandClaude Opus 5.5 0a6f8d3db8 LR-HDR-EXPORT: HLG HDR AVIF export (hdrTransfer) and a tested SDR/HDR display decision
AVIF HDR export takes hdrTransfer = pq | hlg (export dialog: HDR curve;
MCP export schema lists it). HLG writes CICP transfer 18 and no clli box.
The window logs whether it shows HDR; today always SDR (eframe configures
an 8-bit surface), with the platform / surface format / headroom / opt-out
rules unit-tested. Also satisfies clippy 1.99's chunks_exact_to_as_chunks
in spots.rs.

Part of #868.

Sources: ITU-R BT.2100 (HLG OETF, inverse OOTF with system gamma 1.2),
ITU-R BT.2408 (203 cd/m2 reference white at 75 % HLG); own design for
the display decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 03:02:02 +11:00
Robert Woldberg 48c61e80c1 Update progress bar, so large lightroom catalog impots do not appear hung 2026-10-11 09:52:02 -06:00
Alfons HoogervorstandClaude Sonnet 5.5 0e53cfc240 Issue #989: the Export dialog height test expects the macOS row (Add to Apple Photos, 876 pt)
export_dialog_is_unchanged_in_a_tall_window expected 846 pt on every platform.
a6431c15 (add exported files to Apple Photos, #236) put a macOS-only checkbox at
the end of the dialog, so on macOS it is 876 pt tall at 1600x1000 (measured:
[[492 62] - [1107.2 938]]). The test failed on macOS on upstream main (76b53778)
with no other change; it is unchanged on other platforms.

Run on macOS (rustc 1.99.0): the five tests in tests_dialog_fit pass, cargo fmt
is clean, clippy over lightcraft-ui-egui with --all-targets is clean. Not run on
Linux or FreeBSD, where the expectation stays 846.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:42:25 +02:00
Andrew FrahnandClaude Opus 5.5 cc3eb1c846 LR-EXP-DNGOPT: lossy DNG export (DNG 1.4 compression 34892)
DNG export gains a "Lossy" compression: the raw data is demosaiced to linear
camera RGB (OpcodeList3 and the default crop applied), scaled to the resize long
edge when one is set, and stored as 8-bit baseline JPEG tiles at the export
quality. The codes follow a 1/2.2 power curve that a LinearizationTable maps
back to linear 16-bit values; colour matrices, white balance and the profile
look are kept. Available in the export dialog, `app.export`
(`dngCompression: "lossy"`) and the MCP export tool.

Also fixes a new clippy lint (chunks_exact_to_as_chunks) in pipeline/spots.rs
that fails `cargo xtask ci` on Rust 1.99.

Sources: Adobe DNG Specification 1.4+ (Compression 34892, LinearizationTable);
own design for the code curve.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 02:40:10 +11:00
Matthew James SivakandClaude Opus 5.5 20822fa513 LR-VIEW-ZOOM: one rule for which renders the GPU draws (#330)
The loupe sized its slider-drag drafts by its own copy of try_render's
"CPU only" list (soft proof, HDR range). A new CPU-only overlay added to
try_render (e.g. #995's sharpening mask) would have left the loupe
drafting full size on the CPU for its first drag. lightcraft_gpu::handles
is now the single rule: try_render returns NotTried by it and the loupe
asks it, so the two can't drift apart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:32:53 +02:00
Fabio LussiandClaude Opus 5.5 dafa00483a Export: "Same folder as original photo" destination mode
app.export takes destinationMode (specificFolder | sameAsOriginal); the
Export dialog shows it as "Export to". In sameAsOriginal mode each photo
is written into its original's folder plus the optional subfolder, with
the usual conflict policy and original guard per folder. A photo with no
folder on disk or whose folder is missing gets its own error. Saved in
presets and Export with Previous like the other options.

Fixes #956

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:28:40 +02:00
Matthew James SivakandClaude Opus 5.5 c0a1a6443a LR-VIEW-ZOOM: choose the drag draft size from what the render costs (#330)
Review follow-up. "A GPU device exists" does not mean the GPU draws the
view: soft proofs and the HDR range render on the CPU, and a LUT profile,
refined masks or a photo past a GPU limit fall back per render. Those drags
had become full-size CPU renders.

region::DragScale now picks the draft size when a drag starts and keeps it
until the drag ends (no resolution jump mid-gesture): full size where the
GPU draws the view (not soft-proofed, no HDR range) and no earlier drag of
the same control on this photo went over the 16 ms slider budget at full
size; 0.6 otherwise. Full-size drafts are timed as they land (the first,
rebuilding stage caches, is left out; the median decides).

ViewSizes carries one `draft: Option<f32>` instead of a scale and a flag
that could disagree. Tests: DragScale unit tests (budget, lock, hostile
times) and headless drags for a remembered slow drag and a soft-proofed
drag, each checked to fail without its guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:27:24 +02:00
Matthew James SivakandClaude Opus 5.5 7421407d25 LR-VIEW-ZOOM: full-size slider-drag drafts when the GPU renders (#330)
A slider drag rendered the loupe's whole frame at 0.6 of its size, so the
fit view went soft while dragging. On the GPU a canvas-sized frame takes a
few ms, so drags now render at full size there; the CPU keeps the 0.6 draft
(region::drag_scale, CPU_DRAFT_SCALE). The no-window path's canvas cap now
follows an explicit `drafting` flag instead of `scale < 1.0`, so a GPU drag
at 1:1 still never renders every pixel of the photo each frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:27:24 +02:00
Alfons HoogervorstandClaude Sonnet 5.5 dd1a857d49 Issue #989: the ten sidecar repair tests also pass on a case-insensitive volume (item 3, ten of eleven)
The tests were written for a case-sensitive disk. rename.rs sidecars() offers both
.XMP and .xmp per naming and keeps those that exist; on case-insensitive APFS
a.XMP "exists" for a file stored as a.xmp, so the code renames a.XMP while the
fault injectors (Faults twice, PartialRename) compared exact paths with the
lowercase one: the injected failure never fired and the rename the tests expect
to be refused succeeded.

Fault injectors now match by same_path_or_file (equal path, or the same file by
identity); two assertions that compared an exact spelling use same_path_for_volume
(equal, or equal ignoring ASCII case on a case-insensitive volume), and one that
required the lowercase packet to be absent is skipped there, where both spellings
are one file. Helpers live in tests_xmp.rs. Production code is not touched.

Run with rustc 1.99.0 on macOS: case-insensitive APFS, the ten failed before and
pass now; case-sensitive APFS (a disk image), the original tests pass and so do
these. All 13 sidecar::tests_repair tests pass on both. cargo fmt is clean and
clippy over lightcraft-engine with --all-targets is clean. Not touched: the
.xmp -> .XMP spelling change on rename, and the Lightroom folder test (its own PR).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:26:54 +02:00
Fabio LussiandClaude Opus 5.5 15e308c3eb Sort ties by file name, then id
Photos that tie on the sort key (equal ratings, one import batch, a
burst within a second) now sort by file name before id, in
Catalog::query, Recently Added and auto-stack. Split out of #641.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:26:13 +02:00
Alfons HoogervorstandClaude Sonnet 5.5 8760dc4a41 Issue #989: odd_folders_never_fail_the_check skips its non-UTF-8 names where the file system refuses them (item 3, one of eleven)
APFS refuses names that are not valid UTF-8 (EILSEQ, os error 92), so the test's
create_dir_all(...).unwrap() panicked on macOS. The test now probes once with a
\xff folder; where that fails, the non-UTF-8 folder case and the non-UTF-8 entry
are skipped and the catalog-folder and symlink cases still run on a plain-named
folder. Where the probe succeeds (Linux, FreeBSD) the test is unchanged.

Run on macOS (case-insensitive APFS, rustc 1.99.0): the five tests in
tests_lightroom_folder pass, cargo fmt is clean, and clippy over lightcraft-engine
with --all-targets is clean. The ten sidecar tests of item 3 are not touched.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 17:21:31 +02:00
Jason Baldwin 6ec7044ebf Merge slideshow-templates into slideshow-export (origin/main: batch 31; whats-new keeps main's Reliability entry, parity summary refreshed) 2026-10-11 17:18:12 +02:00
Jason Baldwin 54c1048b57 Merge slideshow-titles into slideshow-templates (origin/main: batch 31; open_session keeps both the slide templates dir and main's apple_photos_background, formats locales merged, whats-new keeps main's Reliability entry, parity summary refreshed) 2026-10-11 17:16:36 +02:00
Jason Baldwin d485dc9011 Merge slideshow-overlays into slideshow-titles (origin/main: batch 31; whats-new keeps main's Reliability entry) 2026-10-11 17:14:04 +02:00
Jason Baldwin b2036ba50d Merge slideshow-composer into slideshow-overlays (origin/main: batch 31; whats-new keeps main's Reliability entry) 2026-10-11 17:12:42 +02:00
Jason Baldwin 2138175826 Merge slideshow-playback into slideshow-composer (origin/main: batch 31; locales merged keeping main's values, whats-new keeps main's Reliability entry, parity summary refreshed) 2026-10-11 17:11:12 +02:00
Jason Baldwin d14a44630d Merge origin/main into slideshow-playback (whats-new: batch 31's Reliability entry kept, Slideshow playback after it under 2026-10-11; parity summary refreshed) 2026-10-11 17:09:28 +02:00
Alfons HoogervorstandClaude Sonnet 5.5 2801199bea Issue #989: fill_box reads its buffer with as_chunks::<3>() (clippy 1.99 chunks_exact_to_as_chunks)
Item 4 of #989. Same elements and the same ignored remainder as chunks_exact(3),
checked on lengths 0-9 in a scratch file; the 13 spots tests pass and clippy over
the workspace without the desktop app package is clean under -D warnings. Items
1-3 of #989 (the macOS app build, the /bin/true lock test, eleven engine tests)
are not touched here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 16:56:59 +02:00
Alfons HoogervorstandClaude Sonnet 5.5 8c19c9f60a LR-IMP-CAMERA-COVERAGE: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified)
All 38 files decode from sensor data and develop at full size (9520x6336 and
6336x9520) without error; mean CIE76 dE to the camera JPEG is 6.3-13.9 at 400 px.
The files are not committed and the evidence entry carries no path. Leica is now
14 verified / 47 unverified, the Leica bucket on a verified body 5% -> 21%; the
weighted shares (85% decode, 42% on a verified body) do not move.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 16:54:06 +02:00
Jason Baldwin ccfff38548 Merge slideshow-templates into slideshow-export (origin/main: sidebar and slideshow_export modules side by side; whats-new and tracker summary) 2026-10-11 16:33:00 +02:00
Jason Baldwin b061563383 Merge slideshow-titles into slideshow-templates (origin/main: headless sessions load main's SAM3 model and the slide templates; format-locale keys from both; whats-new and tracker summary) 2026-10-11 16:30:20 +02:00
Jason Baldwin 41c9cd3817 Merge slideshow-overlays into slideshow-titles (origin/main: title-screen RenderResult gains main's gpu_fallback field; tracker summary refreshed) 2026-10-11 16:27:59 +02:00
Jason Baldwin 56d78344c1 Merge slideshow-composer into slideshow-overlays (origin/main: watermark draws through the shared TextLayout with main's quarter-turn rotation, separate insets and preview floor scale; overlay watermark sanitizes insetX/insetY/rotation and maps legacy inset) 2026-10-11 16:26:40 +02:00
Jason Baldwin efaa39b8b5 Merge slideshow-playback into slideshow-composer (origin/main: geocode + slideshow crates side by side in the manifests, locale keys from both, tracker summary refreshed) 2026-10-11 16:22:50 +02:00
Jason Baldwin 7b0fc19ebb Merge origin/main into slideshow-playback (icons: Play/Pause beside grading and appearance icons; settings: Slideshow tab beside appearance cards; render: Slide and CompareRegion slots; tracker summary and whats-new) 2026-10-11 16:19:50 +02:00
Nigel Ihza Firdausy e4f92df78c fix(meta): reject incomplete and mismatched GPX documents 2026-10-11 21:17:17 +07:00
Nigel Ihza Firdausy c41b1e311d fix(geom): reject non-finite affine and projective transforms 2026-10-11 21:16:19 +07:00
Nigel Ihza Firdausy a77b0cc472 fix(meta): reject impossible Gregorian capture dates 2026-10-11 21:14:52 +07:00
dev-null-undefinedandClaude 9e2f383166 LR-PROF-CAMERACOLOR: ILCE-7RM6 colour results — corrected counts
Co-Authored-By: Claude <[email protected]>
2026-10-11 16:10:31 +02:00
dev-null-undefinedandClaude a90cdcd2f3 LR-PROF-CAMERACOLOR: ILCE-7RM6 colour results — per-file fit, no pooled profile
Co-Authored-By: Claude <[email protected]>
2026-10-11 16:10:16 +02:00
dev-null-undefined f4761b459c LR-PROF-CAMERACOLOR: camera-JPEG colour fit accepts previews above 64 MP (ILCE-7RM6 full frame) 2026-10-11 16:09:43 +02:00
Jason Baldwin b0972e4488 Merge slideshow-templates into slideshow-export (origin/main: integration batch 26, #975; no conflicts) 2026-10-11 15:05:18 +02:00
Jason Baldwin 7e4ff5fddf Merge slideshow-titles into slideshow-templates (origin/main: integration batch 26, #975; no conflicts) 2026-10-11 15:05:04 +02:00
Jason Baldwin 857c15f3b3 Merge slideshow-overlays into slideshow-titles (origin/main: integration batch 26, #975; no conflicts) 2026-10-11 15:05:04 +02:00
Jason Baldwin 29203d6eec Merge slideshow-composer into slideshow-overlays (origin/main: integration batch 26, #975; no conflicts) 2026-10-11 15:05:04 +02:00
Jason Baldwin ee8b56f1db Merge slideshow-playback into slideshow-composer (origin/main: integration batch 26, #975; Cargo.lock: lightcraft-slideshow 0.6.0) 2026-10-11 15:05:04 +02:00
Jason Baldwin 8d692ed0a9 Merge origin/main into slideshow-playback (menus.rs ui_enabled: keep main's compare.set and this branch's slideshow.* arm) 2026-10-11 14:51:59 +02:00
MarekSpodBiedry c822501a2b Rotate left/right turns the crop with the image instead of cutting it (#962) 2026-10-11 13:53:41 +02:00
Jason Baldwin b391307009 LRC-SS-EXPORT: slideshow export to PDF, JPEG sequence and AV1 video
View > Export Slideshow... (and Export PDF / JPEG / Video in Settings >
Slideshow) writes the photos in view as their composed slides, between
the intro and ending screens that are on:

- PDF: a page per slide (1024/1920/3840 px long side, JPEG quality),
  /Dur and /Trans fades per page, optional /PageMode /FullScreen. The
  contact sheet's writer moves to engine/src/pdf.rs and streams to disk.
- JPEG: <name>-001.jpg... in a folder.
- Video: AV1 (rav1e, speed 10, low latency) in MP4 or WebM written by
  our own muxers (codecs/src/video.rs); fades rendered as blended frames,
  held slides one key-frame sample with a long duration; 720p/1080p,
  24/30 fps; no audio; capped at 2 hours.

slideshow.export runs in the background with a row in the activity
stack (activity.cancel stops it); outputs are written as .part and
renamed when complete, so cancel or failure leaves nothing behind. A
photo that can't be rendered is skipped with a warning.
slideshow.exportStatus reports the result.
2026-10-11 10:35:09 +02:00
树洋 陈 8e61ad6b13 Add Ctrl+O / Cmd+O to show every mask overlay at once
Previously the masking overlay showed only the selected mask's region
(toggled by O). This adds a new Overlay::AllMasks variant that paints
the evaluated alpha of every visible mask that has components in one
tint, wired to a new "Show All Mask Overlays" command (Ctrl+O on
Windows/Linux, Cmd+O on macOS) reachable from the View menu, the
masking panel header (stack icon), and the keyboard shortcut.

Both the CPU and GPU render paths evaluate all visible masks and hand
their alpha planes to visualize::apply, which layers each mask's
region so multiple masks are visible at once. The all-masks overlay
takes precedence over the single-mask overlay so the shortcut is not
shadowed by the always-on per-mask view.
2026-10-11 15:56:17 +08:00
Jason Baldwin 6bfe2235af LRC-SS-TEMPLATES: slideshow templates: built-ins of our own, user templates, browser with hover preview
A slideshow template is the Playback (not "prepare previews"), Layout /
Options / Backdrop, Overlays and Titles settings as one versioned JSON
document (format lightcraft.slideshowTemplate, version 1).

- Engine: slide_template (SlideLook, Template, built-ins, user template
  files in <settings>/slideshow-templates read through each section's own
  merged() validation; size and count caps; newer versions refused; bad
  files reported and skipped; file names sanitised so ids never leave the
  folder). Applying replaces the four settings in one step and keeps the
  replaced ones as "previous" for the session (prefs have no undo).
- Five built-ins of our own: Plain Black, Gallery Wall, Edge to Edge,
  Cinema Wide, Shooting Data (no Lightroom names or designs, no images).
- Commands slideshow.templates.list/apply/save/update/rename/delete/
  import/export; slideshow.preview {template} previews without applying.
- UI: Template Browser at the top of Settings > Slideshow (built-in, user,
  previous settings; click applies, hover previews on the selected photo,
  right-click update / rename / export / delete; save and import), file
  dialogs on the desktop; strings in the nine locales.
- Docs: parity row (partial), whats-new, control protocol and MCP.
2026-10-11 09:22:11 +02:00
Jason Baldwin f98078415e LRC-SS-TITLES: slideshow intro and ending screens
Intro and ending title screens (Lightroom Classic's Titles panel): each on
or off, a background colour and, centred, the Overlays identity plate or a
line of text, in the plate's colour or an override colour, at a scale, for
its own duration (default: the slide duration).

- Playback timeline: titles are items (Item::Title) of Playback; the intro
  plays once before the first photo, the ending after the last photo when
  repeat is off; random order never moves them; arrows step across them;
  fades, pause and readiness work as for slides; n of N counts photos only.
- Engine: slide_title (TitleSettings in prefs.json, TitleCompose drawing
  through the slide composer and overlay text renderer), slideshow.titles
  get/set, slideshow.preview {title}.
- UI: titles composed on a render worker (Renderer::request_title), the
  control bar shows Intro / End, Settings > Slideshow > Titles section and
  a Photo / Intro / Ending preview choice; strings in the nine locales.
- Docs: parity row (partial), playback note, whats-new, control and MCP.
2026-10-11 08:24:54 +02:00
Jason Baldwin 0a09450809 Merge slideshow-composer (with main) into slideshow-overlays 2026-10-11 06:42:02 +02:00
Jason Baldwin 1b8cb436a8 Merge slideshow-playback (with main) into slideshow-composer
# Conflicts:
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/uk.json
2026-10-11 06:40:14 +02:00
Jason Baldwin 46e431a200 Merge origin/main into slideshow-playback (0.6.0 and shared docking) 2026-10-11 06:40:02 +02:00
Wladmir Júnior 7707379812 pt-BR: translate face recognition and album sorting strings 2026-10-11 00:44:03 -03:00
Jason Baldwin 13dd73eb49 Merge branch 'slideshow-composer' into slideshow-overlays 2026-10-10 23:00:04 +02:00
Jason Baldwin 213047b3ff Merge branch 'slideshow-playback' into slideshow-composer 2026-10-10 23:00:01 +02:00
Jason Baldwin 570b2a10c5 LRC-SS-PLAYBACK: the render-ahead test waits for the slide up too (renders finish in any order) 2026-10-10 22:59:58 +02:00
Jason Baldwin 12dc533396 LRC-SS-OVERLAYS: slideshow overlays: identity plate, rating stars, text with tokens, watermark
- Engine: slide_overlay (OverlaySettings in prefs.json, the new slideshow.overlays
  command): identity plate (text, or a PNG/JPEG file with its transparency; size,
  colour, opacity; render behind image), rating stars (own vector stars), up to 16
  text overlays with tokens ({caption}, {exposure}, {file}, {date}, {focal}… plus
  every rename token; unknown tags show as typed and are reported) and a drop
  shadow, each at one of nine anchors of the slide or photo plus an offset, kept on
  the slide; the export watermark on the photo. Sizes relative to the slide's
  shorter side; validated and clamped.
- Drawn by the engine as a second pass over lightcraft_slideshow::compose_with
  (new behind hook for "render behind image"); the watermark's text renderer moved
  to engine text.rs (TextLayout, Inter + craft-fonts CJK) and is shared; glyphs
  off the image are skipped so huge text stays cheap.
- rename::expand_tokens_with lets overlays add tokens without changing rename.
- Playback composes slides when overlays are on; slideshow.preview returns each
  overlay's box. Settings > Slideshow > Overlays section with live preview.
- Docs: parity row (partial), whats-new, control/MCP docs, nine locale catalogs.
- Tests: tokens, nine anchors, star count, plate image opacity and missing file,
  text shadow, resolution independence, hostile input, big-slide cost, CJK with
  craft-fonts, prefs persistence, preview and headless playback pixel checks.
2026-10-10 22:54:40 +02:00
Jason Baldwin ae033aaf32 LRC-SS-LAYOUT: slideshow layout, options and backdrop: slides composed with margins, stroke, shadow and backdrop
- New crate lightcraft-slideshow (L1, no UI): SlideSettings (margins linked or
  independent, guides, aspect preview, zoom to fill, stroke border, cast shadow,
  backdrop colour, colour wash, background image; lengths relative to the slide's
  shorter side; validated and clamped) and a CPU compose() used by playback, the
  Settings preview and slideshow.preview.
- Engine: slideshow.layout (partial merge, saved in prefs.json) and
  slideshow.preview {id?, width?, height?, path?} (PNG to path or base64);
  RenderJob::with_slide composes on the render worker; the background photo is
  rendered once and shared.
- Playback: with a non-default layout each slide is the composed screen-sized
  slide, prepared ahead like before; the default layout plays as before.
- Settings > Slideshow: Layout, Options and Backdrop sections and a debounced
  live preview of the active photo's slide (with margin guides).
- Tests: composer geometry/pixels/hostile input, command get/set/persist/preview,
  headless playback of a composed slide and of the default layout.
2026-10-10 21:47:42 +02:00
Jason Baldwin 52875ec3f9 Merge origin/main into slideshow-playback (docs restructure #788: parity.md is now parity-checklist.md; slideshow rows re-applied, summary regenerated) 2026-10-10 20:54:29 +02:00
Jason Baldwin 92077d0807 LRC-SS-PLAYBACK: slideshow playback: fades, random order, repeat, previews prepared in advance, playback controls 2026-10-10 20:31:38 +02:00
srikrishna-00andClaude Opus 5.5 c8dff74948 #618: profiles.list includes imported LUT profiles
`profiles.list` enumerated only the built-in `PROFILES`, so a `.cube` LUT
imported with `profile.import` never appeared in it, although the command
promises every profile and `develop.profile` points clients to it for ids.
`profiles.menu` already listed them.

List the built-in profiles first, then the imported LUT profiles marked
`"imported": true` (as in `profiles.menu`), with the same favourite flag.

Test: `cube_luts_become_profiles` now checks the imported profile is listed
with its name, group and favourite flag, still listed after the library is
reopened, gone after `profile.deleteImported`, and that built-ins stay
listed (fails before this change, passes after).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 11:15:17 +05:30
584 changed files with 99247 additions and 5590 deletions

No files matched your search

+19 -38
View File
@@ -1,16 +1,23 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
workflow_dispatch:
concurrency:
@@ -22,34 +29,8 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font build input, so the Japanese font tests run (they skip without it).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+28
View File
@@ -0,0 +1,28 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: nix flake check --no-build --all-systems
+6 -56
View File
@@ -1,7 +1,5 @@
# 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).
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
@@ -21,58 +19,10 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
done
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
- name: FreeBSD tarball layout (stub binaries)
run: bash packaging/freebsd/package.sh --dry-run
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
windows-install-folder:
runs-on: windows-latest
+25 -471
View File
@@ -1,13 +1,5 @@
# Release pipeline: every push to `release` builds platform artifacts (signed where credentials
# are configured) and creates or updates the draft GitHub Release "LightCraft v<version>".
# See docs/releasing.md.
#
# 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, 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.
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "LightCraft v<version>". See docs/releasing.md.
name: Release
on:
@@ -21,471 +13,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
LIGHTCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release` here or on the other jobs that sign nothing: no release secrets.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
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: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
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: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
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
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications'
ldd target/release/lightcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
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
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
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
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
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: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="LightCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: lightcraft
app-title: LightCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+24 -79
View File
@@ -1,22 +1,27 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
# Windows ARM64 build and smoke test.
#
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
workflow_dispatch:
concurrency:
@@ -26,70 +31,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: lightcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: lightcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'LightCraft'
foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'lightcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok lightcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: lightcraft
app-dir: LightCraft
+7 -3
View File
@@ -125,14 +125,18 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
## Map of the code
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
(dev-dependency only). `flake.nix` +
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
in CI: it may lag behind the workspace; see README → Quick start.
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
## Contributor credits (About window)
+9 -4
View File
@@ -16,7 +16,8 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
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.
path the tracker cites still exists and that `docs/gaps.md` lists each gap once, 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).
@@ -132,11 +133,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
## Map of the code
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
(dev-dependency only). `flake.nix` +
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
in CI: it may lag behind the workspace; see README → Quick start.
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
Generated
+58 -33
View File
@@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.1"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -3081,6 +3081,7 @@ dependencies = [
"lightcraft-engine",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-testkit",
"lightcraft-ui-egui",
"log",
"muda",
@@ -3097,7 +3098,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3114,7 +3115,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3130,7 +3131,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3144,6 +3145,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-heif",
"lightcraft-raster",
"lightcraft-testkit",
"moxcms 0.9.1",
"png",
"proptest",
@@ -3159,7 +3161,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3168,7 +3170,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3185,7 +3187,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3200,7 +3202,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3212,7 +3214,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"ab_glyph",
"harfrust",
@@ -3239,6 +3241,8 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
"lightcraft-slideshow",
"lightcraft-testkit",
"lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
@@ -3252,8 +3256,9 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-testkit",
"matrixmultiply",
"ndarray",
"proptest",
@@ -3266,7 +3271,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"log",
"rustls",
@@ -3277,7 +3282,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3285,7 +3290,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3293,17 +3298,19 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"bytemuck",
"lightcraft-color",
"lightcraft-denoise",
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-testkit",
"log",
"pollster 1.0.1",
"wgpu",
@@ -3311,7 +3318,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3319,14 +3326,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3335,7 +3342,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3346,7 +3353,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3362,7 +3369,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3374,7 +3381,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3392,7 +3399,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3404,7 +3411,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3416,14 +3423,16 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"dhat",
"jpeg-encoder",
"jxl-oxide",
"lightcraft-color",
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-raster",
"lightcraft-testkit",
"lightcraft-tiff",
"miniz_oxide 0.8.9",
"proptest",
@@ -3438,7 +3447,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3449,24 +3458,38 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"candle-core",
"candle-nn",
"lightcraft-fetch",
"lightcraft-testkit",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "lightcraft-slideshow"
version = "0.8.0"
dependencies = [
"lightcraft-raster",
"serde",
"serde_json",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.7.1"
version = "0.8.0"
[[package]]
name = "lightcraft-testkit"
version = "0.8.0"
[[package]]
name = "lightcraft-tiff"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3475,7 +3498,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
@@ -3493,6 +3516,8 @@ dependencies = [
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"lightcraft-slideshow",
"lightcraft-testkit",
"log",
"serde",
"serde_json",
@@ -3502,7 +3527,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6972,7 +6997,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"brotli",
"flate2",
+4 -2
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -39,6 +39,8 @@ lightcraft-fetch = { path = "crates/fetch" }
lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
lightcraft-geocode = { path = "crates/geocode" }
lightcraft-slideshow = { path = "crates/slideshow" }
lightcraft-testkit = { path = "crates/testkit" }
# foundation
thiserror = "2"
@@ -52,7 +54,7 @@ bytemuck = { version = "1", features = ["derive"] }
crc32fast = "1"
siphasher = "1"
libc = "0.2" # dev-only: process CPU time in benches
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds
# formats
quick-xml = "0.38"
+4 -3
View File
@@ -51,9 +51,10 @@ no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
(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.
(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans
Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto
Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's
licence as OFL-<family>.txt; Windows installers include their texts in FONT-LICENCES.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
+57 -6
View File
@@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
This is a contact-sheet workflow,
not a printer-driver or custom picture-package layout editor.
- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image,
Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells,
stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper
sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi,
and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command
(`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`,
`print.template.save`…), so the CLI and MCP drive it too:
```sh
lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
```
Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -307,14 +321,14 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
- **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
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% 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;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 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);
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — 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 [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
@@ -337,7 +351,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
| Optics (distortion, vignetting, auto + manual CA, defringe, 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) ⬜ |
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f 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 [gaps](docs/gaps.md)) |
@@ -370,6 +384,38 @@ per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged.
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
override these defaults. Ordinary development commands keep their own settings.
**Release builds on memory-constrained machines:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
reports Arch/AUR release-link OOM kills on a 16 GB machine when the desktop and CLI build concurrently
with full debug information, thin LTO and one codegen unit. The CI defaults above do not apply to
ordinary `cargo build --release`. Start with one job; two may be suitable if enough memory is available:
```sh
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
# Alternatively: CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
```
If a packaging environment enables full debug information or memory-heavy LTO settings, use
command-local overrides for a lower-memory source build:
```sh
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
```
These POSIX-shell assignments affect only this command, not global Cargo configuration or the
repository's release profiles. Disabling LTO may change performance and binary size; disabling debug
information reduces debugging detail. This is build guidance, not a guarantee that any machine has
enough memory. Downstream packagers should serialize large binary builds and avoid full debug
information combined with LTO and one codegen unit on low-memory builders. A PKGBUILD that exports
its own profile settings can override inherited variables; adjust the packaging recipe locally or
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when
those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip
with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU
adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and
`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the
GPU tests on every backend that has a hardware adapter. Nothing is uploaded.
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
```sh
@@ -380,12 +426,14 @@ 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 — 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 →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library.
**Portable mode:** a file named `portable.txt` (or `LightCraft.portable`) next to the executable moves the settings
folder to `LightCraftData` beside it. A
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
Folder** shows it in the file manager). Each start moves the
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
@@ -409,6 +457,9 @@ nix build github:storytold/lightcraft # → ./result/bin/{light
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
```
On NixOS, run a `cargo` build from `nix develop`: outside it the app can't find `libvulkan.so.1`, finds no GPU and
renders on the CPU, much more slowly (it says so once at start-up). `nix run` and `nix build` set the path themselves.
In a flake configuration (NixOS, home-manager, nix-darwin):
```nix
+25 -1
View File
@@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
@@ -359,6 +359,30 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
**内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
报告在 16 GB 的 Arch/AUR 机器上,同时构建桌面应用和 CLI,并启用完整调试信息、thin LTO
和单个代码生成单元时,链接过程被 OOM 终止。上述 CI 默认设置不适用于普通的
`cargo build --release`。建议先使用一个构建任务;内存充足时可以尝试两个:
```sh
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
```
如果打包环境启用了完整调试信息或占用较多内存的 LTO 设置,可以使用仅对本条命令生效的
覆盖设置,降低源码构建的内存需求:
```sh
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
```
这些 POSIX shell 赋值只影响本条命令,不修改全局 Cargo 配置或仓库的 release profile。
关闭 LTO 可能改变性能和二进制大小;关闭调试信息会减少可用的调试细节。这是构建建议,
并不保证任何机器都有足够内存。下游打包者应串行构建大型二进制文件,并避免在低内存
构建机器上同时使用完整调试信息、LTO 和单个代码生成单元。PKGBUILD 自己导出的 profile
设置可以覆盖继承的环境变量;应在本地调整打包配方或联系维护者,而不要假定给
`makepkg` 添加环境变量前缀就能优先生效。
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
```sh
+53 -15
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
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
@@ -12,11 +12,11 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are
| | Value | Kind |
|---|---:|---|
| **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** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | 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 |
| **Mainstream practitioner** | **~51%** (46–56%) | estimated |
| **Essentials user** | **~65%** (60–70%) | 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 |
@@ -25,15 +25,15 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
| 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 |
| Mainstream practitioner | **~51%** | **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 | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
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
estimated rather than measured, CR3 decodes on 26 verified Canon bodies (rarer CRX variants still open as previews), 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
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (compressed ORF and HE NEF open as previews; Canon,
~30% of raw users, decodes CR3 on 26 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).
@@ -45,11 +45,11 @@ Hours are calibrated on this repo's PR history; see
| 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) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 32–54 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) |
| Localization | ~55% 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) |
@@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see
| Area | Ready | Breadth | Remaining |
|---|---:|---:|---:|
| Import and library | 80% | 90% | 25–45 h |
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
| RAW decoding and camera coverage | 77% | 82% | 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 |
@@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
## Upcoming
@@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
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)
4. ~~Full-resolution zoom~~ (done 2026-10-12)
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)
@@ -114,6 +114,26 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
photographers on a verified body; headline numbers unchanged.
- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models
verified, 35 unsupported; headline numbers unchanged.
- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each
file's preview; 3 bodies verified on CC0 files. 240 models verified; headline numbers unchanged.
- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full
size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged.
- 2026-10-12: 22 more Canon CR3 bodies verified (EOS R, RP, R3, R5, R6, R6 Mark II / III, R7, R10, R50,
R50 V, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, 1D X Mark III, PowerShot G5 X II, G7 X III, SX70,
V1): one CC0 file each pinned and checked against the camera JPEG; all 110 CC0 CR3s on raw.pixls.us
decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW
decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%.
- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked
against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800
decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged.
- 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
@@ -130,6 +150,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
/ 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-11: Sony ILCA-99M2 (α99 II) camera colour: bundled profile from 144 photos; on 30 withheld photos
mean ΔE vs the camera JPEG 5.11 → 4.87, 25 of them closer.
- 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.
@@ -161,6 +183,20 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged |
| 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 |
@@ -170,3 +206,5 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 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 |
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (334 models, 23%); headline numbers unchanged |
| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36's CC0 per-model checks) |
+79 -26
View File
@@ -3,9 +3,9 @@
//! ```text
//! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]…
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json|FILE.lcpreset] [--preset ID] [--size N] [--quality Q]
//! 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 merge hdr|panorama|hdr-panorama|hdr-batch [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]
@@ -17,7 +17,7 @@
mod alloc_release;
use std::io::{BufReader, Write};
use std::io::Write;
use std::path::Path;
use std::process::ExitCode;
@@ -55,7 +55,8 @@ USAGE:
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
--settings FILE merge a partial develop-settings JSON file, or apply the one preset of a
.lcpreset (preset.export) or XMP preset
--preset ID apply a preset (see `commands`/presets.list)
--size N long edge in pixels (default: full size, cropped)
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
@@ -79,9 +80,10 @@ USAGE:
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
--size WxH window size in points (default 1600x1000)
--scale S pixels per point (default 1)
lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
prints the result as JSON. Options:
prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken
less than a second apart at different exposures), e.g. a whole folder. Options:
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
--auto-crop --fill-edges --no-auto-settings
@@ -303,10 +305,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
while i < args.len() {
match args[i].as_str() {
"--connect" => {
// Optional address argument.
match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) {
// Optional address argument. A bare port (all digits) is taken as a port on the
// loopback default — matches the help's `--connect [ADDR]` hint alongside the
// `--control 7980` example. A non-numeric, non-`--`-prefixed token (e.g. a
// command id like `ui.inspect`) leaves the address at its default so the
// command is still consumed next. (#1178)
match args.get(i + 1).filter(|a| a.contains(':') || a.chars().all(|c| c.is_ascii_digit())) {
Some(a) => {
connect = Some(a.clone());
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
i += 1;
}
None => connect = Some(DEFAULT_ADDR.to_string()),
@@ -360,8 +366,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
};
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
let mut server = Server::new(backend).with_command_tools(!compact);
let stdin = std::io::stdin();
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
server.serve_stdio(std::io::stdout()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {
@@ -369,7 +374,8 @@ fn merge(args: &[String]) -> Result<(), String> {
Some("hdr") => "merge.hdr",
Some("panorama" | "pano") => "merge.panorama",
Some("hdr-panorama") => "merge.hdrPanorama",
_ => return Err("merge: expected hdr, panorama or hdr-panorama".into()),
Some("hdr-batch") => "merge.hdrBatch",
_ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()),
};
let mut p = serde_json::Map::new();
let mut files = Vec::new();
@@ -525,9 +531,9 @@ fn run(args: &[String]) -> Result<(), String> {
i += 1;
break;
}
"--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) {
"--connect" => match args.get(i + 1).filter(|a| !a.contains('=') && (a.contains(':') || a.chars().all(|c| c.is_ascii_digit()))) {
Some(a) => {
connect = Some(a.clone());
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
i += 1;
}
None => connect = Some(DEFAULT_ADDR.to_string()),
@@ -631,11 +637,41 @@ fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
/// What `render --settings FILE` applies: a partial develop-settings object, or the one preset of
/// a preset file (`.lcpreset` from `preset.export`, XMP), applied as the app applies it. Merging a
/// `.lcpreset` as settings dropped its wrapper and applied nothing (#1730).
enum SettingsFile {
Partial(Value),
Preset(lightcraft_engine::develop::Preset),
}
fn read_settings(p: &str) -> Result<SettingsFile, String> {
use lightcraft_engine::presets::{LCPRESET_EXT, parse_preset_file};
let bytes = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?;
let ext = Path::new(p).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let json = serde_json::from_slice::<Value>(&bytes);
// a settings object never has a top-level `presets` or `settings` key; a preset file always does
let preset_file =
ext == LCPRESET_EXT || ext == "xmp" || json.as_ref().is_ok_and(|v| v.is_array() || v.get("presets").is_some() || v.get("settings").is_some());
if preset_file {
let mut list = parse_preset_file(p, &bytes).map_err(|e| format!("{p}: {e}"))?;
return match (list.pop(), list.len()) {
(Some(preset), 0) => Ok(SettingsFile::Preset(preset)),
(None, _) => Err(format!("{p}: no presets in this file")),
(Some(_), n) => Err(format!("{p}: {} presets in this file; --settings applies one, export it on its own", n + 1)),
};
}
match json.map_err(|e| format!("{p}: {e}"))? {
v @ Value::Object(_) => Ok(SettingsFile::Partial(v)),
_ => Err(format!("{p}: not a develop-settings object or a preset file")),
}
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
let mut values = serde_json::Map::new();
let mut settings: Option<Value> = None;
let mut settings = None;
let mut preset = None;
let mut size: Option<u64> = None;
let mut quality = 92u8;
@@ -650,11 +686,7 @@ fn render(args: &[String]) -> Result<(), String> {
let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?;
values.insert(k.trim().to_string(), json!(v));
}
"--settings" => {
let p = take_value(args, &mut i, "--settings")?;
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?);
}
"--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?),
"--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()),
"--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?),
"--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?,
@@ -680,8 +712,14 @@ fn render(args: &[String]) -> Result<(), String> {
if let Some(p) = preset {
run(&mut s, "preset.apply", json!({"id": p}))?;
}
if let Some(st) = settings {
run(&mut s, "develop.merge", json!({"settings": st}))?;
match settings {
Some(SettingsFile::Partial(st)) => run(&mut s, "develop.merge", json!({"settings": st}))?,
Some(SettingsFile::Preset(mut p)) => {
p.id = "cli.settings".into();
s.presets.push(p);
run(&mut s, "preset.apply", json!({"id": "cli.settings"}))?;
}
None => {}
}
if !values.is_empty() {
run(&mut s, "develop.set", json!({"values": values}))?;
@@ -759,12 +797,15 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
let mut s =
Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None if files.is_empty() => {
Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates()
}
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
@@ -787,6 +828,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
// before the first frame can start background adapter discovery.
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
app.ui.settings.gpu = false;
// as the desktop app opens it: an empty library on the grid, where Import is offered
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
let mut h = Headless::new(app, size, scale);
let timeout = Duration::from_secs(60);
let mut shots = 0usize;
@@ -828,7 +871,17 @@ fn snapshot(args: &[String]) -> Result<(), String> {
match next_path(&mut shots) {
Some(p) => {
wrote_output = true;
params["path"] = json!(p);
// `params` is untrusted script JSON: `params["path"] = …` panics on
// an array or a string. Only an object (or null, like an absent
// `params`) may take the path.
if params.is_null() {
params = json!({});
}
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(p));
} else {
return Err(format!("{script}:{}: ui.screenshot: `params` must be a JSON object", n + 1));
}
}
None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)),
}
+127
View File
@@ -47,6 +47,48 @@ fn contact_sheet_exports_pdf_and_protects_originals() {
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
}
#[test]
fn print_commands_chain_into_a_picture_package_pdf() {
let input = tmp("print-input.png");
gradient_png(&input);
let out = tmp("print.pdf");
let (ok, lines, err) = run_cli(
&[
"--import",
input.to_str().unwrap(),
"print.set",
r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#,
r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#,
"job={\"ppi\":72}",
"print.cell.add",
"width=1.5",
"height=1",
"print.cell.add",
"width=1.5",
"height=1",
"print.layout.autoLayout",
"print.exportPdf",
&format!("path={}", out.display()),
],
None,
);
assert!(ok, "{err}: {lines:?}");
let export = lines.last().unwrap();
assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1)));
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
let text = String::from_utf8_lossy(&bytes);
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in");
// a bad setting fails the run and writes nothing new
let before = std::fs::read(&out).unwrap();
let (ok, _, _) =
run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None);
assert!(!ok);
assert_eq!(std::fs::read(&out).unwrap(), before);
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
@@ -139,6 +181,51 @@ fn render_subcommand() {
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
}
/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper
/// and rendered the photo unedited.
#[test]
fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() {
let input = tmp("lcpreset-in.png");
gradient_png(&input);
let preset = tmp("bright.lcpreset");
let (ok, lines, err) = run_cli(
&[
"--import",
input.to_str().unwrap(),
"develop.set",
"control=light.exposure",
"value=1",
"preset.create",
"name=Bright",
"preset.export",
&format!("path={}", preset.display()),
],
None,
);
assert!(ok, "{err}: {lines:?}");
let plain = tmp("lcpreset-plain.png");
let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
assert!(st.success());
let bright = tmp("lcpreset-bright.png");
let o = Command::new(BIN)
.args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()])
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
assert!(mean(&bright) > mean(&plain) + 10.0, "the preset's exposure was not applied");
let other = tmp("other.lcpreset");
std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
let out = tmp("lcpreset-other.png");
let o = Command::new(BIN)
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()])
.output()
.unwrap();
assert!(!o.status.success(), "an unreadable settings file was accepted");
assert!(String::from_utf8_lossy(&o.stderr).contains("other.lcpreset"), "{}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "nothing is written when the settings can't be read");
}
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
#[test]
fn render_refuses_to_overwrite_its_input() {
@@ -273,6 +360,24 @@ fn snapshot_script_failure_exits_non_zero() {
assert_eq!((d.width, d.height), (320, 200));
}
/// A script line whose `params` is not an object must be an error, never a panic: the
/// `ui.screenshot`-without-a-path branch used to index (`params["path"] = …`) an array and abort
/// the whole script.
#[test]
fn snapshot_script_with_non_object_params_is_an_error() {
let script = tmp("snap-bad-params.jsonl");
let out = tmp("snap-bad-params.png");
std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap();
let o = Command::new(BIN)
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(!o.status.success(), "{stderr}");
assert!(!stderr.contains("panicked"), "a bad `params` must be an error, not a panic: {stderr}");
assert!(stderr.contains("`params` must be a JSON object"), "{stderr}");
}
#[test]
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
let script = tmp("snap-ui-zoom.jsonl");
@@ -476,6 +581,28 @@ fn connect_failure_hint_names_the_attempted_port() {
assert!(!err.contains("--control 7980"), "{err}");
}
/// Issue #1178: `--connect 7980 ui.inspect` (after `lightcraft --control 7980`) was leaving
/// `7980` unconsumed so it parsed as a command id and produced "unknown command `7980`".
/// A bare port is now taken as `127.0.0.1:<port>`; the same address used with `:port` still
/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through.
#[test]
fn connect_bare_port_is_treated_as_loopback_port() {
// Bare port: the failure hint names that port.
let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}");
assert!(!err.contains("--control 7980"), "{err}");
// `<host>:<port>` still works (issue #167 case).
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "{err}");
// `--connect=<bare-port>` already worked (the `a.starts_with("--connect=")` arm); spot-check
// the hint matches its port too.
let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "{err}");
}
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
#[test]
+38
View File
@@ -0,0 +1,38 @@
//! A broken protocol output must not require the client to close its input pipe.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[test]
fn broken_stdout_exits_with_stdin_still_open() {
let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli"))
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
drop(child.stdout.take());
input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap();
input.flush().unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
child.kill().unwrap();
child.wait().unwrap();
panic!("MCP did not exit after stdout failed while stdin stayed open");
}
std::thread::sleep(Duration::from_millis(10));
};
let mut stderr = String::new();
child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap();
assert!(stderr.contains("serving MCP on stdio"), "CLI must reach the MCP transport: {stderr}");
assert!(!status.success(), "broken protocol output must be reported: {stderr}");
assert!(stderr.contains("lightcraft-cli:"), "CLI must report the transport error: {stderr}");
drop(input);
}
+3
View File
@@ -219,6 +219,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
save_keyword_list: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
pick_slideshow_templates: None,
save_slideshow_template: None,
write: Some(Box::new(download)),
// downloads happen on the main thread: exports run in the foreground on the web
write_shared: None,
@@ -228,6 +230,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
reveal: None,
log_file: None,
open_with: None,
open_all_with: None,
check_editor: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
+7 -1
View File
@@ -41,6 +41,9 @@ pub struct WireJob {
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
/// The original's long edge (`SourceInfo::native_long`).
#[serde(default)]
pub native_long: Option<usize>,
pub settings: Arc<DevelopSettings>,
pub max_w: usize,
pub max_h: usize,
@@ -64,7 +67,7 @@ impl WireJob {
/// `view`: the slot name, for jobs that keep a stage cache.
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
let max_edge = match &job.source {
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge,
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
SourceRef::Smart { .. } => 2560,
};
@@ -80,6 +83,7 @@ impl WireJob {
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
native_long: job.info.native_long,
settings: job.settings.clone(),
max_w: job.request.max_w,
max_h: job.request.max_h,
@@ -101,6 +105,7 @@ impl WireJob {
lens: self.lens,
relative_wb: self.relative_wb,
legacy_relative_wb: self.legacy_relative_wb,
native_long: self.native_long,
..Default::default()
}
}
@@ -469,6 +474,7 @@ mod tests {
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
native_long: None,
settings: Arc::new(DevelopSettings::default()),
max_w: 32,
max_h: 32,
+10
View File
@@ -161,6 +161,8 @@ struct Busy {
level: SourceLevel,
key: u64,
cache: Option<(Arc<PreviewCache>, Hash128)>,
/// A slideshow slide: the worker renders the photo, it is composed here when it comes back.
slide: Option<Arc<lightcraft_engine::slide::SlideCompose>>,
}
struct W {
@@ -370,6 +372,13 @@ impl Workers {
});
}
g.remote_done += 1;
let rendered = match (rendered, &b.slide) {
(Ok(mut r), Some(slide)) => {
r.image = slide.compose(&r.image);
Ok(r)
}
(r, _) => r,
};
g.done.push((
b.slot,
RenderResult {
@@ -493,6 +502,7 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
cache: job.cache,
slide: job.slide,
});
None
}
+4
View File
@@ -48,3 +48,7 @@ muda = { workspace = true }
objc2 = { workspace = true }
objc2-app-kit = { workspace = true }
objc2-foundation = { workspace = true }
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
+28 -1
View File
@@ -51,7 +51,7 @@ fn decode(code: u32) -> Option<egui::Theme> {
/// 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 exe = gsettings_exe(std::env::var_os("PATH").as_deref())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
@@ -59,6 +59,15 @@ fn gtk_theme() -> Option<egui::Theme> {
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
/// `gsettings` by absolute path: the absolute `PATH` entries first (NixOS has no /usr/bin/gsettings),
/// then the usual places for a sparse `PATH`. Relative entries are skipped, so nothing runs from
/// the working folder.
#[cfg(target_os = "linux")]
fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
let listed = path.map(std::env::split_paths).into_iter().flatten().filter(|dir| dir.is_absolute());
listed.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|dir| dir.join("gsettings")).find(|exe| exe.is_file())
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
@@ -174,6 +183,24 @@ mod tests {
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
}
/// NixOS keeps `gsettings` in /run/current-system/sw/bin or the per-user profile: it is found
/// on `PATH`, but never through a relative entry, even one that leads to a real file.
#[cfg(target_os = "linux")]
#[test]
fn gsettings_is_found_on_absolute_path_entries_only() {
let dir = std::env::temp_dir().join(format!("lightcraft-gsettings-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("gsettings"), "").unwrap();
let path = std::env::join_paths([std::path::Path::new("relative/bin"), &dir]).unwrap();
assert_eq!(super::gsettings_exe(Some(&path)), Some(dir.join("gsettings")));
let cwd = std::env::current_dir().unwrap();
let relative = std::path::PathBuf::from("../".repeat(cwd.components().count() - 1)).join(dir.strip_prefix("/").unwrap());
assert!(relative.join("gsettings").is_file());
let found = super::gsettings_exe(Some(relative.as_os_str()));
assert!(found.as_ref().is_none_or(|p| p.is_absolute()), "{found:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_values_and_gsettings_output_decode() {
+125
View File
@@ -0,0 +1,125 @@
//! The ICC profile the desktop assigns to the display (Linux, through colord).
//!
//! GNOME, KDE and most other desktops keep each monitor's profile in colord
//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a
//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile
//! is chosen in Settings ▸ Display, previews are shown through that profile
//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never
//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB.
//!
//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a
//! window is on), so the profile is used only when every display that has one has the same: a
//! mixed set stays sRGB rather than guess.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
/// How often colord is asked again (a profile change in the desktop's settings shows within this).
const POLL: Duration = Duration::from_secs(3);
/// A wait before trying again when colord isn't there (not installed, no system bus).
const RETRY: Duration = Duration::from_secs(30);
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
const MAX_PROFILE: u64 = 16 << 20;
const SERVICE: &str = "org.freedesktop.ColorManager";
const DEVICE: &str = "org.freedesktop.ColorManager.Device";
const PROFILE: &str = "org.freedesktop.ColorManager.Profile";
/// Start the background reader and return the service the UI polls (`Services::screen_profile`).
pub fn service() -> Box<dyn FnMut() -> Option<Vec<u8>>> {
let shared: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
let writer = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || {
loop {
let wait = match connect() {
Some(conn) => {
let icc = read(&conn);
*writer.lock().unwrap_or_else(PoisonError::into_inner) = icc;
POLL
}
None => RETRY,
};
std::thread::sleep(wait);
// the app is gone: nobody reads the profile any more
if Arc::strong_count(&writer) == 1 {
return;
}
}
});
if let Err(e) = spawned {
log::warn!("screen profile: no colord reader: {e}");
}
Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone())
}
fn connect() -> Option<zbus::blocking::Connection> {
zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok()
}
/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays).
fn read(conn: &zbus::blocking::Connection) -> Option<Vec<u8>> {
let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?;
let devices: Vec<zbus::zvariant::OwnedObjectPath> = manager.call("GetDevicesByKind", &("display",)).ok()?;
let mut files = Vec::new();
for device in devices.iter().take(64) {
let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue };
let profiles: Vec<zbus::zvariant::OwnedObjectPath> = proxy.get_property("Profiles").unwrap_or_default();
// colord lists a device's profiles default first
let Some(first) = profiles.first() else { continue };
let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue };
if let Ok(file) = profile.get_property::<String>("Filename") {
files.push(file);
}
}
let file = pick(&files)?;
load(std::path::Path::new(file))
}
/// The profile to use for these displays' profile files: the one they share, `None` with none or
/// with different ones (empty names are ignored).
fn pick(files: &[String]) -> Option<&str> {
let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty());
let first = named.next()?;
named.all(|f| f == first).then_some(first)
}
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
fn load(path: &std::path::Path) -> Option<Vec<u8>> {
use std::io::Read as _;
let file = std::fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shared_profile_is_used_and_a_mixed_set_is_not() {
let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::<Vec<_>>();
assert_eq!(pick(&s(&[])), None);
assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc"));
assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc"));
assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known");
assert_eq!(pick(&s(&[""])), None);
}
#[test]
fn profile_files_are_read_with_a_size_cap() {
let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let small = dir.join("small.icc");
std::fs::write(&small, b"acsp").unwrap();
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
let empty = dir.join("empty.icc");
std::fs::write(&empty, b"").unwrap();
assert_eq!(load(&empty), None);
assert_eq!(load(&dir.join("missing.icc")), None);
let big = dir.join("big.icc");
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
assert_eq!(load(&big), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+95 -1
View File
@@ -55,16 +55,65 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
}
}
/// How long a request waits for its reply.
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
/// A request the app has not picked up while it drew no frame for this long is given up: the
/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no
/// frame callbacks, so the app's loop is paused: issue #718).
const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
/// The reply for a request given up because the app drew no frames.
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT))
});
}
/// Wait for a request's reply. While the app has not picked the request up and `frames` (its
/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late)
/// and the reply says why; a request the app is running gets the whole `timeout`.
fn await_reply(
rrx: &Receiver<Value>,
state: &std::sync::atomic::AtomicU8,
frames: impl Fn() -> u64,
wake: impl Fn(),
no_frames: Duration,
timeout: Duration,
) -> Value {
let start = std::time::Instant::now();
let slice = (no_frames / 10).max(Duration::from_millis(1));
let (mut last, mut since) = (frames(), std::time::Instant::now());
loop {
match rrx.recv_timeout(slice) {
Ok(v) => return v,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
let now = frames();
if now != last {
(last, since) = (now, std::time::Instant::now());
}
let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN;
if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) {
return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR});
}
if start.elapsed() >= timeout {
// not run yet: make sure it never is; already running: it may still finish
ControlRequest::abandon(state);
return json!({"ok": false, "error": "timeout"});
}
wake();
}
}
/// One parsed line.
enum Line {
Request {
@@ -160,6 +209,51 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
/// up with a reason, quickly, and the app then skips it instead of running it late.
#[test]
fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let t0 = std::time::Instant::now();
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10));
assert!(t0.elapsed() < Duration::from_secs(2));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}");
assert!(!req.take(), "the app does not run it once it shows up");
}
/// A request the app is running is not given up because frames stopped (a long command
/// blocks the frame loop), and its reply arrives.
#[test]
fn a_request_being_run_waits_for_its_reply() {
let (req, rrx) = ControlRequest::new("export.run", json!({}));
assert!(req.take());
let reply = req.reply.clone();
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
let _ = reply.send(json!({"ok": true, "result": 1}));
});
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
worker.join().unwrap();
}
/// Frames still coming: the request just waits, then is answered.
#[test]
fn a_drawing_app_is_waited_for() {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let n = std::sync::atomic::AtomicU64::new(0);
let reply = req.reply.clone();
let state = Arc::clone(&req.state);
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
let _ = reply.send(json!({"ok": true, "result": 2}));
});
let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
worker.join().unwrap();
}
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
+56 -2
View File
@@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
/// still starting when it is started once per file. The system default opens them one by one
/// (Explorer and `xdg-open` take one file).
pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> {
match all_command(launcher(app)?, paths) {
Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()),
None => paths.iter().try_for_each(|p| open_with(p, app)),
}
}
/// The one command that opens all of `paths` in `launcher` (none for the system default).
fn all_command(launcher: Launcher, paths: &[String]) -> Option<Command> {
match launcher {
Launcher::Default => None,
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app]).args(paths);
Some(c)
}
Launcher::Program(p) => {
let mut c = Command::new(p);
c.args(paths);
Some(c)
}
Launcher::Flatpak => {
let mut c = Command::new("flatpak");
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@");
Some(c)
}
}
}
/// 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).
@@ -172,8 +205,10 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
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())
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
program.starts_with('/').then(|| PathBuf::from(program))
}
#[cfg(test)]
@@ -191,6 +226,25 @@ mod tests {
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
}
/// Open Original with several photos: one start of the editor with every file, which a
/// single-window editor (DxO PhotoLab) needs to get them all.
#[test]
fn several_files_go_to_one_start_of_the_editor() {
let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()];
let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap();
assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe");
assert_eq!(args(&c), paths);
let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap();
assert_eq!(
(c.get_program().to_string_lossy().into_owned(), args(&c)),
("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()])
);
let c = all_command(Launcher::Flatpak, &paths).unwrap();
assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]);
assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one");
}
#[test]
fn windows_lookup_prefers_the_recorded_install_folder() {
let env = |k: &str| match k {
+174
View File
@@ -0,0 +1,174 @@
//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR
//! white, and with which surface format. Pure decisions, unit-tested here; the window always
//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong.
//!
//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer,
//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol),
//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal
//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the
//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the
//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same
//! decision turns HDR on.
use eframe::wgpu::TextureFormat;
/// Windowing platform, as far as HDR output is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
MacOs,
Windows,
Wayland,
X11,
Web,
Other,
}
impl Platform {
/// The platform this build runs on (`wayland` from the session environment on Linux / BSD).
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::MacOs
} else if cfg!(target_os = "windows") {
Self::Windows
} else if cfg!(target_arch = "wasm32") {
Self::Web
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
Self::Wayland
} else if std::env::var_os("DISPLAY").is_some() {
Self::X11
} else {
Self::Other
}
}
/// Whether the platform has an HDR compositing path wgpu can reach at all.
fn has_hdr_path(self) -> bool {
matches!(self, Self::MacOs | Self::Windows | Self::Wayland)
}
}
/// How HDR values reach the display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrEncoding {
/// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB,
/// Wayland extended linear).
ExtendedLinear,
/// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ).
Pq10,
}
/// The display output decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayOutput {
Sdr { reason: &'static str },
Hdr { format: TextureFormat, encoding: HdrEncoding },
}
impl DisplayOutput {
#[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR
pub fn is_hdr(self) -> bool {
matches!(self, Self::Hdr { .. })
}
}
/// The encoding a surface format carries on `platform`, if it is an HDR one.
fn encoding_of(format: TextureFormat, platform: Platform) -> Option<HdrEncoding> {
match format {
TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear),
// macOS takes HDR through EDR floats only
TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10),
_ => None,
}
}
/// The best HDR surface format among those a surface offers (`available`), for `platform`:
/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only.
/// (For configuring the surface once eframe lets the app pick its format.)
#[cfg_attr(not(test), allow(dead_code))]
pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option<TextureFormat> {
if !platform.has_hdr_path() {
return None;
}
[TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some())
}
/// The decision for the window: `target` is the format the renderer configured (`None`: the glow
/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the
/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS),
/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`).
pub fn choose(target: Option<TextureFormat>, platform: Platform, display_headroom: Option<f32>, disabled: bool) -> DisplayOutput {
if disabled {
return DisplayOutput::Sdr { reason: "HDR display is switched off" };
}
if !platform.has_hdr_path() {
return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" };
}
let Some(format) = target else {
return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" };
};
let Some(encoding) = encoding_of(format, platform) else {
return DisplayOutput::Sdr { reason: "the window surface is 8-bit" };
};
match display_headroom {
Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding },
Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" },
None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" },
}
}
/// The decision for this start, logged once (the window renderer's `target` format).
pub fn report(target: Option<TextureFormat>) -> DisplayOutput {
let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
// no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready
let out = choose(target, Platform::current(), None, disabled);
match out {
DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"),
DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"),
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use TextureFormat as F;
#[test]
fn eight_bit_surfaces_are_sdr() {
for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] {
for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] {
assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}");
}
}
}
#[test]
fn hdr_needs_platform_format_and_headroom() {
let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear };
assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr);
assert_eq!(
choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false),
DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 }
);
// any missing condition falls back to SDR
assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr());
assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr());
// macOS has no PQ swap chain path
assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr());
}
#[test]
fn preferred_format_picks_float_then_pq() {
let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float];
assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float));
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm));
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None);
assert_eq!(preferred_hdr_format(&all, Platform::X11), None);
assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None);
}
}
+354 -10
View File
@@ -24,9 +24,12 @@
mod alloc_release;
mod appearance;
#[cfg(target_os = "linux")]
mod colord;
mod control_server;
mod dialog_filter;
mod editors;
mod hdr_display;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -73,10 +76,13 @@ impl eframe::App for App {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0);
self.0.ui(ui);
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
// (a size the window was just given is remembered too)
self.0.ui.window = self.1.window_at_exit(self.0.ui.window);
if let Err(e) = self.1.save(&self.0) {
log::error!("{e}");
}
@@ -87,6 +93,16 @@ impl eframe::App for App {
if !self.0.shutdown(EXIT_WAIT) {
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
}
// macOS (#1331): end the process here, before eframe tears the window down, as Quit from
// the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's
// content view and restores its class on teardown; AppKit's Touch Bar support observes
// that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation
// invalidate]` throws "not registered" and closing the window ends in a crash report.
// Nothing after this is needed: settings and library are saved, background work has
// stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a
// renderer that fails to start still relaunches on the next one (`window_gpu`).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -149,6 +165,46 @@ fn gpu_crash_notice(what: &str) -> String {
)
}
/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next
/// launch renders on the CPU and says why.
fn gpu_lost_marker_path() -> Option<std::path::PathBuf> {
gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker"))
}
/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a
/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it
/// never failed.
fn gpu_lost_reason() -> Option<String> {
lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string))
}
/// Record that LightCraft stopped after the GPU was lost, for the next launch.
fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) {
let Some(m) = marker else { return };
if let Some(d) = m.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(m, format!("{reason}\n")) {
log::warn!("could not write {}: {e}", m.display());
}
}
/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a
/// `--memory` session, which leaves the disk as it found it).
fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option<String> {
let m = marker?;
let reason = std::fs::read_to_string(m).ok()?;
if !in_memory {
let _ = std::fs::remove_file(m);
}
Some(format!(
"LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \
is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \
an up-to-date graphics driver may help.",
reason.trim()
))
}
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
fn display_profile_dir() -> Option<std::path::PathBuf> {
@@ -170,6 +226,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
}
/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size
/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it
/// opens is the system's to say.
fn as_remembered(builder: egui::ViewportBuilder, window: Option<lightcraft_ui_egui::window_place::WindowPlace>) -> egui::ViewportBuilder {
let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size));
// (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen
// to a size of its own choosing)
if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder }
}
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
fn prefs_path() -> Option<std::path::PathBuf> {
@@ -288,6 +354,11 @@ struct PrefsWriter {
failing: bool,
/// `ui.json` couldn't be read at launch: never overwrite it this session.
keep_file: bool,
/// Follows the window, to remember the size it rests at (`UiState::window`).
window: lightcraft_ui_egui::window_place::WindowWatch,
/// When the window was first looked at: its watch goes by the clock from then on, not by
/// the interface's time, which stands still while the window is hidden.
window_since: Option<std::time::Instant>,
}
impl PrefsWriter {
@@ -318,8 +389,33 @@ impl PrefsWriter {
Ok(())
}
/// What to remember of the window as the app quits ([`WindowWatch::at_exit`]).
///
/// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit
fn window_at_exit(
&mut self,
remembered: Option<lightcraft_ui_egui::window_place::WindowPlace>,
) -> Option<lightcraft_ui_egui::window_place::WindowPlace> {
match self.window_since {
Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()),
None => remembered,
}
}
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
// (asked for before the input is read: the context is not asked from inside its own lock)
let zoom = ctx.zoom_factor();
let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)));
// how the window is, to open like that next time (written with the settings below). What
// the settings hold is checked first: a script may have set it (`ui.set`)
let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64();
app.ui.window = self.window.note(remembered, &seen, at);
if let Some(wait) = self.window.waits(at) {
// (a size is only remembered once the window has rested at it: look again then, though
// nothing else asks for a frame)
ctx.request_repaint_after(wait);
}
let moved = app.ui.settings.library_path != self.library;
if !moved && now - self.checked < 3.0 {
return;
@@ -449,7 +545,16 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
pick_folder: Some(Box::new(|| {
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
#[cfg(target_os = "linux")]
open_model_folder: Some(std::sync::Arc::new(|path: &str| {
std::process::Command::new("xdg-open")
.arg(path)
.status()
.map_err(|e| e.to_string())
.and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) })
})),
open_with: Some(Box::new(editors::open_with)),
open_all_with: Some(Box::new(editors::open_all_with)),
check_editor: Some(Box::new(editors::check)),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
@@ -525,7 +630,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
// 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"))]
// Linux: the profile the desktop assigns in colord, read off the UI thread
#[cfg(target_os = "linux")]
screen_profile: Some(colord::service()),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = file_dialog()
@@ -578,6 +686,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_slideshow_templates: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_slideshow_template: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
@@ -591,10 +717,12 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
}
}
/// The library session with the folder face models are kept in (`<config>/models`).
/// The library session with the folders face models (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in.
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();
s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(s, problem)
@@ -649,7 +777,8 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder).
Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData)
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;
@@ -782,7 +911,7 @@ fn main() -> eframe::Result {
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
// GPU compute: off if the preference says so, or if the last launch died creating the device
// (issue #136) — before anything can create it
let gpu_crash = gpu_crash_check(in_memory);
let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), 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
@@ -791,12 +920,15 @@ fn main() -> eframe::Result {
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();
// the window came up: a failure after this is LightCraft stopping, not a start that failed
let came_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let came_up_app = came_up.clone();
// how the window was when the app was last used (checked: the settings file is a file)
let opens_as = prefs.as_ref().and_then(|ui| ui.window).and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
let options = eframe::NativeOptions {
renderer,
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as)
.with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE)
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
@@ -828,11 +960,14 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
came_up_app.store(true, std::sync::atomic::Ordering::Relaxed);
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();
// HDR display: SDR unless the surface, platform and display all allow HDR
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
@@ -842,6 +977,12 @@ fn main() -> eframe::Result {
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
app.notices.extend(window_notice);
app.gpu_notice = true;
// an empty library opens where Import is offered (a grid, with the sidebar); before
// the snapshot below, so this alone isn't written to the settings file
if problem.is_none() && files.is_empty() {
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
}
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
@@ -886,6 +1027,31 @@ fn main() -> eframe::Result {
}),
)
}));
if came_up.load(std::sync::atomic::Ordering::Relaxed) {
// the window was up: a panic that escaped it now is LightCraft stopping, typically the
// window's renderer after the graphics driver reset the GPU (issue #1337: egui-wgpu panics
// on the lost device). Not a failed start: no other renderer is tried, and the message
// says what happened.
let stopped = match started {
Ok(r) => r,
Err(_) => Err(eframe::Error::AppCreation("its window stopped drawing (the renderer panicked)".into())),
};
if let Err(e) = &stopped {
let lost = gpu_lost_reason();
if let Some(reason) = &lost
&& !in_memory
{
record_gpu_lost(gpu_lost_marker_path().as_deref(), reason);
}
// (not "app creation error: …": the app had been created)
let what = match e {
eframe::Error::AppCreation(m) => m.to_string(),
e => e.to_string(),
};
stopped_while_running(&stopped_text(&what, lost.as_deref()), log_file.as_deref());
}
return stopped;
}
// 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())));
@@ -900,6 +1066,34 @@ fn main() -> eframe::Result {
started
}
/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU
/// was lost (`lost`): GPU rendering is off from the next start.
fn stopped_text(error: &str, lost: Option<&str>) -> String {
match lost {
Some(reason) => format!(
"{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \
Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on"
),
None => error.to_string(),
}
}
/// LightCraft stopped while its window was up: say so in the log and in a native message box
/// naming the log file (as [`startup_failed`] does for a start that failed).
fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft stopped: {text}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
/// message box naming the log file, instead of exiting without a trace — the release builds have
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
@@ -1102,6 +1296,37 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an
/// export, and the dialog said the window "could not open". The stop is recorded; the next
/// launch says why once and renders on the CPU (a `--memory` session leaves the record).
#[test]
fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() {
let d = dir("gpu-lost");
let m = d.join("gpu-lost.marker");
assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded");
assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder");
record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost");
let notice = gpu_lost_check_at(Some(&m), true).unwrap();
assert!(
notice.contains("(device lost (Unknown): Device is lost)")
&& notice.contains("GPU rendering is now off")
&& notice.contains("Use the GPU for rendering"),
"{notice}"
);
assert!(m.exists(), "a --memory session leaves it");
assert!(gpu_lost_check_at(Some(&m), false).is_some());
assert!(!m.exists());
assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once");
// the message box: LightCraft stopped (not "could not open its window"), and why
let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost"));
assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}");
assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing");
let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log"));
assert_eq!(title, "LightCraft stopped");
assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}");
let _ = std::fs::remove_dir_all(&d);
}
/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed.
#[test]
fn gpu_crash_marker_is_reported_once() {
@@ -1207,6 +1432,125 @@ mod tests {
assert!(loaded.is_some() && warning.is_none());
let _ = std::fs::remove_dir_all(&d);
}
/// Given what the settings remember of the window, when the app's window is built, then it
/// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it
/// opens at the usual size.
#[test]
fn the_window_opens_as_it_was() {
use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace};
let fresh = as_remembered(egui::ViewportBuilder::default(), None);
assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None));
let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true }));
assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true)));
// (not asked to un-zoom: macOS would then resize a window that fills its screen)
let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }));
assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None));
}
/// Given a window that rests at a size, when the settings are written and read again, then
/// they hold how it was. A window in the file that is not one (a hand edit, another version's)
/// reads as nothing remembered and costs no other setting; and a window resized just before
/// the app quits is remembered at that size.
#[test]
fn the_settings_keep_how_the_window_was() {
use lightcraft_ui_egui::window_place::{Seen, WindowPlace};
let d = dir("window");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.ui.settings.library_path = "/pictures/library".into();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save(&app).unwrap();
assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet");
let window = WindowPlace { size: [1300.0, 800.0], zoomed: true };
app.ui.window = Some(window);
writer.save(&app).unwrap();
let (restored, warning, _) = load_prefs_at(&path, false);
assert_eq!((restored.unwrap().window, warning), (Some(window), None));
// a window that is no window: the file is read all the same, with its other settings
let text = std::fs::read_to_string(&path).unwrap();
for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] {
let start = text.find("\"window\"").unwrap();
let end = start + text[start..].find('}').unwrap() + 1;
std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, true);
let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}"));
assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}");
}
// resized, and the app quits a moment later: the new size is what is written
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false });
writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0);
writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400));
assert!(writer.window_since.is_some());
assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false }));
assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen");
let _ = std::fs::remove_dir_all(&d);
}
/// Given the app's window at a size, when the settings writer looks at it frame after frame,
/// then it asks to be woken while the size has still to rest, remembers the size once it has,
/// and asks for nothing more. A window in the settings that is not one (a script set it) is
/// put right before it is gone by.
#[test]
fn the_writer_follows_the_window_from_frame_to_frame() {
use lightcraft_ui_egui::window_place::WindowPlace;
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
let mut writer = PrefsWriter::default();
let ctx = egui::Context::default();
let pass = |look: &mut dyn FnMut(&egui::Context)| {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| look(ui.ctx()));
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay)
};
// (egui's own first frames ask for another: let them pass)
for _ in 0..3 {
pass(&mut |_| {});
}
app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true });
let asked = pass(&mut |ctx| writer.tick(&mut app, ctx));
assert_eq!(app.ui.window, None, "no window, and not yet at rest");
assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}");
// a second later
writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1)));
pass(&mut |ctx| writer.tick(&mut app, ctx));
assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }));
let asked = pass(&mut |ctx| writer.tick(&mut app, ctx));
assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}");
}
/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
/// The check runs in a child (this test binary again), since on macOS it ends the process.
#[test]
fn on_exit_ends_the_process_on_macos() {
if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
let mut app = App(
app,
PrefsWriter::default(),
#[cfg(target_os = "macos")]
None,
);
println!("calling on_exit");
eframe::App::on_exit(&mut app, None);
println!("on_exit returned");
return;
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
.env("LIGHTCRAFT_TEST_ON_EXIT", "1")
.env("LIGHTCRAFT_NO_PREFS", "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{stdout}");
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
}
#[cfg(test)]
+4 -2
View File
@@ -26,8 +26,9 @@ const QUIT: &str = "app.quit";
const SETTINGS: &str = "app.settings";
const SETTINGS_KEY: &str = "Cmd+,";
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
/// or the active mask): left to the egui handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete"];
/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui
/// handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"];
/// Shortcuts text fields need while they have focus.
const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"];
@@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
"`" => Code::Backquote,
"," => Code::Comma,
"Delete" => Code::Backspace,
// with modifiers only: a bare Enter belongs to text fields and tools
+2 -2
View File
@@ -278,11 +278,11 @@ mod tests {
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
eprintln!("no GPU adapter: skipped");
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
eprintln!("no GPU device: skipped");
lightcraft_testkit::skip!(Gpu, "no GPU device");
return;
};
install_error_handler(&device);
+9 -1
View File
@@ -12,20 +12,28 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/panels/person.rs` | Up arrow on the People page bulk-confirm control, drawn in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-10-13 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18
View File
@@ -25,5 +25,23 @@ sync_github_name = false
[people.rock-meister]
sync_github_name = true
[people.Th4nat0s]
display_name = "Thanat0s"
[people.sanyamsingh001]
display_name = "sanyamsingh001"
[people.ArekatlaNishanthchowdary]
real_name = "Arekatla Nishanth Chowdary"
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FooVoo]
real_name = "Aleksandr Batyrev"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)]
cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)]
+101
View File
@@ -0,0 +1,101 @@
//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library
//! don't scan a list each time: which stack a photo is in, whether a regular album holds it.
//!
//! They are never saved (the on-disk format is unchanged). Each is built on first use from what
//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts
//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to
//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`,
//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the
//! rollback of a failed batch included, as those are ops too.
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use crate::{AlbumId, Catalog, PhotoId, StackId};
#[derive(Default)]
pub(crate) struct Indexes {
/// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks
/// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts.
stacks: OnceLock<Option<HashMap<PhotoId, StackId>>>,
/// Album → its photos (regular albums; a smart album's list is empty).
albums: OnceLock<HashMap<AlbumId, HashSet<PhotoId>>>,
}
/// A copy builds its own when asked (copies are made to be saved, not queried).
impl Clone for Indexes {
fn clone(&self) -> Self {
Indexes::default()
}
}
/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was
/// built.
impl PartialEq for Indexes {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for Indexes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len));
f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish()
}
}
impl Indexes {
/// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone).
pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) {
let Some(Some(m)) = self.stacks.get_mut() else { return };
for p in old {
if m.get(p) == Some(&id) {
m.remove(p);
}
}
for p in new {
m.insert(*p, id);
}
}
/// Album `id` holds `photos` now (`None`: it is gone).
pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) {
let Some(m) = self.albums.get_mut() else { return };
match photos {
Some(photos) => {
m.insert(id, photos.iter().copied().collect());
}
None => {
m.remove(&id);
}
}
}
}
impl Catalog {
/// The stack photo `photo` is in, by the index (built on first use).
pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option<StackId> {
let m = self.index.stacks.get_or_init(|| {
let mut m = HashMap::new();
for s in self.stacks.values() {
for p in &s.photos {
if m.insert(*p, s.id).is_some_and(|other| other != s.id) {
return None;
}
}
}
Some(m)
});
match m {
Some(m) => m.get(&photo).copied(),
// in id order, the first stack listing the photo has it
None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id),
}
}
/// Whether album `album`'s own list holds `photo`, by the index (built on first use).
pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool {
let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect());
m.get(&album).is_some_and(|s| s.contains(&photo))
}
}
+11 -2
View File
@@ -50,6 +50,8 @@
//! | 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` |
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -74,7 +76,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 = 7;
pub const VERSION: u32 = 9;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
@@ -112,6 +114,8 @@ pub struct LoadReport {
/// The library was in an older catalog format (`0` = before format versions: a log without a
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
pub upgraded_from: Option<u32>,
/// What [`Catalog::repair`] changed in a damaged snapshot (empty for a sound one).
pub repaired: Vec<String>,
}
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
@@ -327,7 +331,12 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
(s.catalog, s.seq, Some(h.version))
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
log::warn!("{SNAPSHOT}: {r}");
}
(catalog, s.seq, Some(h.version))
}
None => (Catalog::new(), 0, None),
};
+68 -38
View File
@@ -236,63 +236,84 @@ pub struct KeywordNode {
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
use std::collections::{HashMap, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
use std::collections::HashMap;
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent)
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
struct Level {
name: String,
sort: String,
path: String,
count: usize,
parent: Option<String>,
seen: usize,
}
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
let mut levels: Vec<Level> = Vec::new();
let mut by_lower: HashMap<String, usize> = HashMap::new();
// each level's children (by index), and the top level
let mut kids: Vec<Vec<usize>> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
// the keyword list first (no photos counted): a listed keyword is there without photos
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
// each keyword as written → its levels (its own and those above it), worked out once: a
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
this_photo.clear();
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
for k in keywords {
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
let chain = chains.entry(k.as_str()).or_insert_with(|| {
let mut chain: Vec<usize> = Vec::new();
let mut path = String::new();
let mut lower = String::new();
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
let sort = part.to_lowercase();
lower.push_str(&sort);
let i = match by_lower.get(&lower) {
Some(&i) => i,
None => {
let i = levels.len();
match chain.last().and_then(|&p| kids.get_mut(p)) {
Some(k) => k.push(i),
None => roots.push(i),
}
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
kids.push(Vec::new());
by_lower.insert(lower.clone(), i);
i
}
};
chain.push(i);
}
path.push_str(part);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if counts && this_photo.insert(lower.clone()) {
l.count += 1;
chain
});
if counts {
for &i in chain.iter() {
let Some(l) = levels.get_mut(i) else { continue };
// photo 0 is the keyword list, which counts nothing
if l.seen != photo {
l.seen = photo;
l.count += 1;
}
}
parent = Some(lower.clone());
}
}
}
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
list.into_iter()
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
.filter_map(|i| {
let lv = levels.get(i)?;
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
let children = build(below, levels, kids);
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
})
.collect()
}
build(None, &levels, &mut kids)
build(roots, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
@@ -804,6 +825,15 @@ mod tests {
assert_eq!(c.query(&f, &Default::default()).len(), 3);
}
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
/// photo with it counts again.
#[test]
fn tree_counts_a_repeated_keyword_once_per_photo() {
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
let t = c.keyword_tree();
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
}
#[test]
fn rename_delete_merge_are_single_undoable_batches() {
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
+324 -19
View File
@@ -15,6 +15,7 @@
pub mod dates;
pub mod folders;
mod index;
pub mod journal;
pub mod keywords;
pub mod local;
@@ -25,6 +26,7 @@ pub mod rules;
pub mod safe_file;
pub mod stacks;
pub mod store;
pub mod tally;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -125,6 +127,12 @@ pub enum Op {
id: PhotoId,
step: HistoryStep,
},
/// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps.
SetHistoryCursor {
id: PhotoId,
#[serde(default)]
cursor: Option<usize>,
},
AddAlbum {
album: Album,
},
@@ -208,6 +216,10 @@ pub enum Op {
/// `None` = its raw data decodes (or it isn't a raw).
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
/// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty
/// when it is `None`.
#[serde(default, skip_serializing_if = "String::is_empty")]
preview_checked: String,
},
/// The lens data a photo's file carries (what Reload finds when it was read after import).
SetEmbeddedLens {
@@ -275,6 +287,74 @@ pub struct Catalog {
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
#[serde(skip)]
changes: Changes,
/// Lookups derived from the above (never saved), see [`index`].
#[serde(skip)]
index: index::Indexes,
}
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
/// rating changes no folder's count; a slider changes none at all).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Aspects(u16);
impl Aspects {
pub const NONE: Aspects = Aspects(0);
pub const RATING: Aspects = Aspects(1);
pub const FLAG: Aspects = Aspects(1 << 1);
pub const LABEL: Aspects = Aspects(1 << 2);
/// Develop settings, versions and history.
pub const DEVELOP: Aspects = Aspects(1 << 3);
/// Title, caption, keywords, people, place and the rest of the metadata.
pub const META: Aspects = Aspects(1 << 4);
pub const CAPTURED: Aspects = Aspects(1 << 5);
/// Where its file is.
pub const FILE: Aspects = Aspects(1 << 6);
pub const ANALYSIS: Aspects = Aspects(1 << 7);
/// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local.
pub const PRESENCE: Aspects = Aspects(1 << 8);
/// Everything: the photo may count differently for any tally.
pub const ALL: Aspects = Aspects(u16::MAX);
pub const fn with(self, other: Aspects) -> Aspects {
Aspects(self.0 | other.0)
}
/// Whether any of `other` is among these.
pub const fn any_of(self, other: Aspects) -> bool {
self.0 & other.0 != 0
}
}
/// The most photos remembered as touched; older changes are then answered with "anything may have changed".
const MAX_TOUCHED: usize = 8192;
/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can
/// follow a small edit without being worked out again from every photo.
#[derive(Clone, Debug, Default)]
struct Changes {
/// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first.
touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>,
/// What the op being applied changes of its photo.
now: Aspects,
/// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of
/// the list…): what it changed for which photos is not known.
broad: u64,
/// Changes up to this revision are no longer all in `touched`.
floor: u64,
/// The revision the last op recorded here made. When the catalog's revision is another, it was set from
/// outside (a library just loaded takes a range of its own, so that nothing kept from another library is
/// taken for this one's): nothing is known of how the catalog got to where it is.
seen: u64,
}
impl PartialEq for Changes {
/// Two catalogs are the same catalog whatever each remembers of how it got there.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Catalog {
@@ -284,19 +364,21 @@ impl Catalog {
// ---- ids
// (saturating: a counter a damaged snapshot left at the top hands out its last id again, which
// the op that adds it then refuses as taken, rather than overflowing)
pub fn alloc_photo_id(&mut self) -> PhotoId {
let id = PhotoId(self.next_photo.max(1));
self.next_photo = id.0 + 1;
self.next_photo = id.0.saturating_add(1);
id
}
pub fn alloc_album_id(&mut self) -> AlbumId {
let id = AlbumId(self.next_album.max(1));
self.next_album = id.0 + 1;
self.next_album = id.0.saturating_add(1);
id
}
pub fn alloc_stack_id(&mut self) -> StackId {
let id = StackId(self.next_stack.max(1));
self.next_stack = id.0 + 1;
self.next_stack = id.0.saturating_add(1);
id
}
@@ -357,13 +439,18 @@ impl Catalog {
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
// guarded: a smart album testing smart albums can't loop or recurse without end
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
Some(a) => a.photos.contains(&p.id),
Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p),
Some(a) => self.album_lists(a.id, p.id),
None => false,
}
}
/// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`.
fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool {
// guarded: a smart album testing smart albums can't loop or recurse without end
!p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared))
}
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
/// search, they don't repeat it.
@@ -411,7 +498,10 @@ impl Catalog {
/// The photos of an album: the stored list, or a smart album's current matches (id order).
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(),
Some(Album { smart: Some(rules), .. }) => {
let prepared = rules.prepare();
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect()
}
Some(a) => a.photos.clone(),
None => Vec::new(),
}
@@ -421,7 +511,10 @@ impl Catalog {
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
// counted in place: no id list is built just for its length
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
Some(Album { smart: Some(rules), .. }) => {
let prepared = rules.prepare();
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count()
}
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}
@@ -471,27 +564,117 @@ impl Catalog {
// ---- writes
fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> {
self.touch(id);
self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id))
}
/// The op being applied changes photo `id` (or adds or removes it).
fn touch(&mut self, id: PhotoId) {
// (the revision the op will make: `apply` counts up once it is done)
self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now));
while self.changes.touched.len() > MAX_TOUCHED {
if let Some((revision, _, _)) = self.changes.touched.pop_front() {
self.changes.floor = revision;
}
}
}
/// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were
/// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only
/// other things of a photo is still right.
pub fn aspects_since(&self, revision: u64) -> Option<Aspects> {
if !self.remembers(revision) {
return None;
}
Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a)))
}
/// Whether every change since the catalog was at `revision` is remembered photo by photo.
fn remembers(&self, revision: u64) -> bool {
self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision
}
/// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not
/// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may
/// have changed.
pub fn touched_since(&self, revision: u64) -> Option<Vec<PhotoId>> {
if !self.remembers(revision) {
return None;
}
let mut ids: Vec<PhotoId> = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect();
ids.sort_unstable();
ids.dedup();
Some(ids)
}
fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> {
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
}
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
if self.changes.seen != self.revision {
// the revision was set from outside: what is remembered is of another past
self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() };
}
let inv = self.apply_inner(op)?;
self.revision += 1;
self.changes.seen = self.revision;
Ok(inv)
}
fn apply_inner(&mut self, op: Op) -> Result<Op> {
// An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind
// can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's
// name. Listed kind by kind, so a new one has to be put on one side.
match &op {
// (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the
// rare kinds whose reach nobody has to think about)
Op::AddPhoto { photo } => {
self.changes.now = Aspects::ALL;
self.touch(photo.id);
}
Op::RemovePhoto { id } => {
self.changes.now = Aspects::ALL;
self.touch(*id);
}
Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => {
self.changes.now = Aspects::ALL
}
Op::SetRating { .. } => self.changes.now = Aspects::RATING,
Op::SetFlag { .. } => self.changes.now = Aspects::FLAG,
Op::SetLabel { .. } => self.changes.now = Aspects::LABEL,
Op::SetMeta { .. } => self.changes.now = Aspects::META,
Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED,
Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE,
Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS,
Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => {
self.changes.now = Aspects::DEVELOP
}
// (its ops say for themselves, one by one)
Op::Batch { .. } => {}
Op::AddAlbum { .. }
| Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumOrder { .. }
| Op::SetAlbumPhotos { .. }
| Op::SetAlbumCover { .. }
| Op::SetAlbumRules { .. }
| Op::AddStack { .. }
| Op::RemoveStack { .. }
| Op::SetStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. }
| Op::SetKeyword { .. }
| Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1),
}
Ok(match op {
Op::AddPhoto { photo } => {
if self.photos.contains_key(&photo.id) {
return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id)));
}
let id = photo.id;
self.next_photo = self.next_photo.max(id.0 + 1);
self.next_photo = self.next_photo.max(id.0.saturating_add(1));
self.photos.insert(id, Arc::new(*photo));
Op::RemovePhoto { id }
}
@@ -545,12 +728,25 @@ impl Catalog {
Op::PushHistory { id, step } => {
let p = self.photo_mut(id)?;
let old = p.history.clone();
let old_cursor = p.history_cursor.take();
// a new edit made from an earlier step drops the steps after it
if let Some(c) = old_cursor {
p.history.truncate(c.saturating_add(1));
}
p.history.push(step);
if p.history.len() > HISTORY_LIMIT {
let n = p.history.len() - HISTORY_LIMIT;
p.history.drain(..n);
}
Op::SetHistory { id, history: old }
if old_cursor.is_none() {
Op::SetHistory { id, history: old }
} else {
Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] }
}
}
Op::SetHistoryCursor { id, cursor } => {
let p = self.photo_mut(id)?;
Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) }
}
Op::AddAlbum { album } => {
if self.albums.contains_key(&album.id) {
@@ -568,7 +764,8 @@ impl Catalog {
self.validate_rules(rules)?;
}
let id = album.id;
self.next_album = self.next_album.max(id.0 + 1);
self.next_album = self.next_album.max(id.0.saturating_add(1));
self.index.album_changed(id, Some(&album.photos));
self.albums.insert(id, album);
Op::RemoveAlbum { id }
}
@@ -577,6 +774,7 @@ impl Catalog {
return Err(CatalogError::Invalid("folder is not empty".into()));
}
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
self.index.album_changed(id, None);
Op::AddAlbum { album: a }
}
Op::RenameAlbum { id, name } => {
@@ -588,13 +786,15 @@ impl Catalog {
if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) {
return Err(CatalogError::Invalid("parent must be another folder".into()));
}
// no cycles
// no cycles (bounded: a loop elsewhere in a damaged catalog must not hang this)
let mut cur = Some(p);
let mut steps = 0usize;
while let Some(c) = cur {
if c == id {
if c == id || steps > self.albums.len() {
return Err(CatalogError::Invalid("cannot move a folder into itself".into()));
}
cur = self.albums.get(&c).and_then(|a| a.parent);
steps += 1;
}
}
let a = self.album_mut(id)?;
@@ -614,13 +814,16 @@ impl Catalog {
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
}
Op::SetAlbumPhotos { id, photos } => {
let a = self.album_mut(id)?;
// the field, not `album_mut`: the index is updated while the album is borrowed
let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?;
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
return Err(CatalogError::Invalid(
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
));
}
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
let old = std::mem::replace(&mut a.photos, photos);
self.index.album_changed(id, Some(&a.photos));
Op::SetAlbumPhotos { id, photos: old }
}
Op::SetAlbumCover { id, cover } => {
let a = self.album_mut(id)?;
@@ -640,12 +843,14 @@ impl Catalog {
}
self.validate_stack(stack.id, &stack.photos)?;
let id = stack.id;
self.next_stack = self.next_stack.max(id.0 + 1);
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
self.index.stack_changed(id, &[], &stack.photos);
self.stacks.insert(id, stack);
Op::RemoveStack { id }
}
Op::RemoveStack { id } => {
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
self.index.stack_changed(id, &s.photos, &[]);
Op::AddStack { stack: s }
}
Op::SetStack { id, photos, collapsed } => {
@@ -654,7 +859,9 @@ impl Catalog {
}
self.validate_stack(id, &photos)?;
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed };
let old_photos = std::mem::replace(&mut s.photos, photos);
self.index.stack_changed(id, &old_photos, &s.photos);
let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed };
s.collapsed = collapsed;
old
}
@@ -677,7 +884,7 @@ impl Catalog {
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
}
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
@@ -686,6 +893,7 @@ impl Catalog {
file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked),
}
}
Op::SetEmbeddedLens { id, lens } => {
@@ -804,7 +1012,98 @@ impl Catalog {
}
pub fn from_snapshot(s: &str) -> Result<Catalog> {
serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))
let mut c: Catalog = serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))?;
c.repair();
Ok(c)
}
/// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left
/// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it
/// finds in them, and walks folders up to the top): every photo, album and stack carries the
/// id it is filed under; the id counters lie past every id in use; a stack keeps only photos
/// in the catalog, once each, and goes when fewer than two are left; an album whose parent is
/// missing, not a folder or inside itself moves to the top level. Returns what was changed
/// (nothing, for a catalog this build wrote).
pub fn repair(&mut self) -> Vec<String> {
let mut changed = Vec::new();
for (id, p) in self.photos.iter_mut() {
if p.id != *id {
changed.push(format!("photo {} was filed as {}", p.id.0, id.0));
Arc::make_mut(p).id = *id;
}
}
for (id, a) in self.albums.iter_mut() {
if a.id != *id {
changed.push(format!("album {} was filed as {}", a.id.0, id.0));
a.id = *id;
}
}
let photos = &self.photos;
self.stacks.retain(|id, st| {
if st.id != *id {
changed.push(format!("stack {} was filed as {}", st.id.0, id.0));
st.id = *id;
}
let mut seen = std::collections::HashSet::new();
let before = st.photos.len();
st.photos.retain(|p| photos.contains_key(p) && seen.insert(*p));
if st.photos.len() != before {
changed.push(format!("stack {}: {} missing or repeated photo(s) left out", id.0, before - st.photos.len()));
}
let keep = st.photos.len() >= 2;
if !keep {
changed.push(format!("stack {} had fewer than two photos: removed", id.0));
}
keep
});
// a photo in two stacks stays in the first
let mut stacked = std::collections::HashSet::new();
self.stacks.retain(|id, st| {
let before = st.photos.len();
st.photos.retain(|p| stacked.insert(*p));
if st.photos.len() != before {
changed.push(format!("stack {}: {} photo(s) already in another stack left out", id.0, before - st.photos.len()));
}
st.photos.len() >= 2
});
let parents: Vec<(AlbumId, Option<AlbumId>)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
for (id, parent) in parents {
let Some(p) = parent else { continue };
let mut cur = Some(p);
let mut steps = 0usize;
let ok = loop {
match cur {
None => break true,
Some(c) if c == id || steps > self.albums.len() => break false,
Some(c) => match self.albums.get(&c) {
Some(a) if a.folder => cur = a.parent,
_ => break false,
},
}
steps += 1;
};
if !ok && let Some(a) = self.albums.get_mut(&id) {
changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
a.parent = None;
}
}
let past = |last: Option<u64>| last.map_or(1, |v| v.saturating_add(1));
let (photo, album, stack) = (
past(self.photos.keys().last().map(|k| k.0)),
past(self.albums.keys().last().map(|k| k.0)),
past(self.stacks.keys().last().map(|k| k.0)),
);
if self.next_photo < photo || self.next_album < album || self.next_stack < stack {
changed.push("id counters behind the ids in use: moved past them".into());
self.next_photo = self.next_photo.max(photo);
self.next_album = self.next_album.max(album);
self.next_stack = self.next_stack.max(stack);
}
if !changed.is_empty() {
// stacks and albums changed outside `apply`: the lookups are built again when asked
self.index = index::Indexes::default();
}
changed
}
/// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored.
@@ -853,10 +1152,16 @@ mod tests_folders;
#[cfg(test)]
mod tests_format_version;
#[cfg(test)]
mod tests_index;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_lock;
#[cfg(test)]
mod tests_repair;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_torn_append;
+26 -9
View File
@@ -183,15 +183,7 @@ impl Catalog {
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
let now_s = crate::stacks::iso_seconds(now);
let max_age = i64::from(days) * 86_400;
let mut referenced: HashSet<PhotoId> = HashSet::new();
for a in self.albums.values() {
referenced.extend(a.photos.iter().copied());
referenced.extend(a.cover);
}
for s in self.stacks.values() {
referenced.extend(s.photos.iter().copied());
}
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
let referenced = self.referenced();
// the fingerprints are the costly part: work them out for the candidates in parallel
let candidates: Vec<&Photo> = self
.photos
@@ -237,6 +229,30 @@ impl Catalog {
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
plan
}
/// Of `ids`, the untouched Local records nothing refers to (no album, stack or virtual copy):
/// the ones [`Catalog::forget_local_plan`] could forget, whatever their folder's time.
pub fn forgettable_local(&self, ids: &[PhotoId]) -> Vec<PhotoId> {
let referenced = self.referenced();
let candidates: Vec<&Photo> =
ids.iter().filter(|id| !referenced.contains(id)).filter_map(|id| self.photos.get(id)).filter(|p| p.local).map(|p| &**p).collect();
let untouched = untouched_of(&candidates);
ids.iter().copied().filter(|id| untouched.contains(id)).collect()
}
/// Photos an album (as a member or its cover), a stack or a virtual copy refers to.
fn referenced(&self) -> HashSet<PhotoId> {
let mut referenced: HashSet<PhotoId> = HashSet::new();
for a in self.albums.values() {
referenced.extend(a.photos.iter().copied());
referenced.extend(a.cover);
}
for s in self.stacks.values() {
referenced.extend(s.photos.iter().copied());
}
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
referenced
}
}
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
@@ -279,6 +295,7 @@ impl Op {
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetHistoryCursor { id, .. }
| Op::SetCaptured { id, .. }
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
+3 -2
View File
@@ -135,8 +135,9 @@ impl LibraryLock {
}
/// 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)]
/// between fork and exec (a `dup` refers to the same open file description). Tests only (the
/// test that uses it is Unix-only: `flock` semantics).
#[cfg(all(test, unix))]
pub(crate) fn descriptor_copy(&self) -> Option<File> {
self.file.as_ref().and_then(|f| f.try_clone().ok())
}
+61 -7
View File
@@ -199,6 +199,11 @@ pub struct Meta {
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub regions: Vec<lightcraft_meta::Region>,
/// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony
/// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never
/// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus: Option<lightcraft_meta::FocusPoint>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -247,6 +252,11 @@ pub struct Photo {
pub versions: Vec<Version>,
#[serde(default)]
pub history: Vec<HistoryStep>,
/// The History step the photo's settings are at, when it isn't the latest one (Go to History
/// Step moved back): later steps stay listed and can be returned to until a new edit drops
/// them. `None` (and catalogs written before the cursor existed) = the latest step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_cursor: Option<usize>,
/// In "Recently Deleted".
#[serde(default)]
pub deleted: bool,
@@ -288,6 +298,12 @@ pub struct Photo {
/// picture style baked in — so it is treated as a rendered (non-raw) source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_only: Option<String>,
/// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when
/// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp
/// differs from the running version is rechecked when its library opens (issue #662): newer
/// builds may decode the file, older ones may decode less.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub preview_checked: String,
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
/// whether the user changed anything since (see [`crate::local`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -313,20 +329,37 @@ pub struct Analysis {
/// 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))
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].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.
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor
/// white-balance presets to read Kelvin from. 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). A camera in `lightcraft_raw::kelvin_presets` does the same through
/// its own presets. 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()
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
}
/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every
/// workspace crate shares one version, so it is the app's.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version
/// when the photo is preview-only, empty otherwise.
pub fn preview_stamp(preview_only: &Option<String>) -> String {
if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() }
}
impl Photo {
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
pub fn history_index(&self) -> Option<usize> {
let last = self.history.len().checked_sub(1)?;
Some(self.history_cursor.map_or(last, |c| c.min(last)))
}
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
id,
@@ -347,6 +380,7 @@ impl Photo {
edited: None,
versions: Vec::new(),
history: Vec::new(),
history_cursor: None,
deleted: false,
local: false,
duration: None,
@@ -358,9 +392,20 @@ impl Photo {
import_look: None,
analysis: None,
preview_only: None,
preview_checked: String::new(),
local_baseline: None,
}
}
/// Sets [`Photo::preview_only`] as this build decided it, with its stamp.
pub fn set_preview_only(&mut self, preview_only: Option<String>) {
self.preview_checked = preview_stamp(&preview_only);
self.preview_only = preview_only;
}
/// A preview-only photo whose flag was set by another app version (or before stamps existed),
/// so this build should probe its file again (issue #662).
pub fn preview_only_stale(&self) -> bool {
self.preview_only.is_some() && self.preview_checked != APP_VERSION
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
/// its embedded preview ([`Photo::preview_only`]).
pub fn develops_raw(&self) -> bool {
@@ -564,6 +609,7 @@ mod edited_tests {
("a.cr2", "CR2", true),
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.erf", "ERF", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
@@ -596,9 +642,13 @@ mod edited_tests {
("SONY ILCE-7RM4A", "ARW", false),
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("Canon Canon EOS R6m2", "CR3", false),
("Canon Canon EOS R7", "CR3", true),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
("SONY ILCE-7M2", "ARW", true),
("SONY ILCA-99M2", "ARW", false),
("SONY SLT-A33", "ARW", false),
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
@@ -609,7 +659,11 @@ mod edited_tests {
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}");
assert_eq!(
p.fitted_look(),
format == "ARW" || format == "NEF" || format == "CR2" || format == "CR3" || 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}");
+107 -30
View File
@@ -1,5 +1,7 @@
//! Filtering, search and sorting.
use std::cmp::Ordering::Equal;
use serde::{Deserialize, Serialize};
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
@@ -92,6 +94,27 @@ pub struct Sort {
pub seed: u64,
}
impl Sort {
/// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last).
pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering {
let o = match self.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if self.ascending { o } else { o.reverse() }
}
}
impl Default for Sort {
fn default() -> Self {
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
@@ -112,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
mix64(mix64(seed) ^ id.0)
}
/// What [`Filter::prepare`] works out once for a pass over many photos.
pub(crate) struct Prepared {
root: Option<String>,
only: Option<std::collections::HashSet<PhotoId>>,
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
@@ -234,7 +263,18 @@ impl Filter {
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
self.matches_in(p, cat, self.library_root().as_deref())
self.matches_in(p, cat, &self.prepare_one())
}
/// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed).
pub(crate) fn prepare_one(&self) -> Prepared {
Prepared { root: self.library_root(), only: None }
}
/// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than
/// once per photo: the library folder's identity and the `only` ids as a set.
pub(crate) fn prepare(&self) -> Prepared {
Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) }
}
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
@@ -244,15 +284,15 @@ impl Filter {
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
}
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
/// over a big library does that once, not once per photo.
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
/// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a
/// big library does that once, not once per photo.
pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool {
if p.deleted != self.deleted {
return false;
}
match &self.folder {
// browsed photos only show in folder views
None if p.local => return false,
// browsed photos only show in folder views, and in Recently Deleted once deleted (#1430)
None if p.local && !p.deleted => return false,
None => {}
Some(dir) => {
let crate::Source::File { path } = &p.source else { return false };
@@ -285,7 +325,7 @@ impl Filter {
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.only.is_empty() && !self.only.contains(&p.id) {
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
return false;
}
if let Some(want) = &self.merged {
@@ -325,7 +365,7 @@ impl Filter {
{
return false;
}
if let Some(root) = root
if let Some(root) = prepared.root.as_deref()
&& !photo_in_root(p, root)
{
return false;
@@ -359,37 +399,74 @@ impl Filter {
}
}
/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`).
pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase))
}
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
let root = filter.library_root();
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
}
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter)
}
/// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort`
/// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
/// the album, the folder; not deleted).
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
let prepared = filter.prepare();
// `within` only decides for the few kept photos: worked out per photo, not hashed
let within_prepared = within.prepare_one();
let mut v: Vec<&Photo> = self
.photos()
.map(|p| p.as_ref())
.filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared)))
.collect();
v.sort_by(|a, b| sort.order(a, b));
v.into_iter().map(|p| p.id).collect()
}
/// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops
/// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos
/// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a
/// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What
/// the query would give, without asking every photo again.
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one));
for id in touched {
let Some(p) = self.photo(*id) else { continue };
let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp));
if filter.matches_in(p, self, &prepared) || kept {
// (the list is in order without the touched photos, and stays so as each is put back)
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
ids.insert(at, *id);
}
}
ids
}
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
pub fn count(&self, filter: &Filter) -> usize {
let prepared = filter.prepare();
self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count()
}
/// Year → month → day counts for the "By Date" section (newest first).
pub fn date_groups(&self) -> Vec<DateGroup> {
use std::collections::BTreeMap;
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
for p in self.photos().filter(|p| p.in_library()) {
let Some(d) = p.captured.as_deref() else { continue };
if d.len() >= 10 {
let (months, days) = years.entry(&d[..4]).or_default();
*months.entry(&d[..7]).or_default() += 1;
*days.entry(&d[..10]).or_default() += 1;
// `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's
// captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index
// would panic — the same rule as `dates::group_label`.
if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) {
let (months, days) = years.entry(year).or_default();
*months.entry(month).or_default() += 1;
*days.entry(day).or_default() += 1;
}
}
let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::<Vec<_>>();
@@ -430,8 +507,8 @@ impl Catalog {
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
let root = filter.library_root();
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
let prepared = filter.prepare();
for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) {
let mut seen: Vec<String> = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
+33 -4
View File
@@ -598,6 +598,35 @@ pub fn now() -> String {
}
}
/// A flag as rules name it (its variant, lower case), without building a string per photo.
fn flag_name(f: crate::Flag) -> &'static str {
match f {
crate::Flag::None => "none",
crate::Flag::Pick => "pick",
crate::Flag::Reject => "reject",
}
}
/// A colour label's colour as rules name it (lower case).
fn label_colour(l: crate::ColorLabel) -> &'static str {
match l {
crate::ColorLabel::Red => "red",
crate::ColorLabel::Yellow => "yellow",
crate::ColorLabel::Green => "green",
crate::ColorLabel::Blue => "blue",
crate::ColorLabel::Purple => "purple",
}
}
/// A media kind as rules name it (lower case).
fn kind_name(k: crate::MediaKind) -> &'static str {
match k {
crate::MediaKind::Image => "image",
crate::MediaKind::Raw => "raw",
crate::MediaKind::Video => "video",
}
}
fn lower(v: &Value) -> String {
match v {
Value::String(s) => s.trim().to_lowercase(),
@@ -778,16 +807,16 @@ impl Rule {
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
let f = format!("{:?}", p.flag).to_lowercase();
let f = flag_name(p.flag);
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
}
"label" => {
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
let l = p.label.map_or("none", label_colour);
// custom label names count too
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want));
(l == want || named) == (op == "is")
}
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
"kind" => (kind_name(p.kind) == want) == (op == "is"),
"edited" => yes(p.is_edited()),
"hasGps" => yes(m.gps.is_some()),
"virtualCopy" => yes(p.copy_of.is_some()),
+33 -9
View File
@@ -20,8 +20,8 @@
//! it is then simply exported again.
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
//! and sync, plus a read-back check, and the file then appears under its final name **without
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
//! volume has no hard links).
//! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard
//! links, an exclusive create that claims the name, then a rename over that placeholder).
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
//! resolved paths elsewhere), for "is this target an original?" checks.
@@ -226,16 +226,24 @@ fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
match fs::hard_link(tmp, path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
Err(_) => {
if fs::symlink_metadata(path).is_ok() {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
}
fs::rename(tmp, path)
}
// no hard links on this volume (FAT/exFAT, some shares)
Err(_) => publish_new_by_rename(tmp, path, &|| {}),
}
}
/// Fallback of [`publish_new`]: reserve the name with an exclusive create (atomic, so a file that
/// appears at the same time is never replaced), then rename the complete temp file over our own
/// empty placeholder. `before_claim` runs just before the name is claimed (a test hook).
fn publish_new_by_rename(tmp: &Path, path: &Path, before_claim: &dyn Fn()) -> io::Result<()> {
before_claim();
OpenOptions::new().write(true).create_new(true).open(path)?;
if let Err(e) = fs::rename(tmp, path) {
let _ = fs::remove_file(path);
return Err(e);
}
Ok(())
}
/// Does the file at `path` hold exactly `data`?
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
use std::io::Read;
@@ -296,6 +304,22 @@ mod tests {
v
}
#[test]
fn fallback_publish_never_replaces_a_file_that_appears_at_the_same_time() {
let d = temp("nolink-race");
let tmp = d.join(".tmp-new");
let dest = d.join("IMG_1.dng");
fs::write(&tmp, b"new").unwrap();
let e = publish_new_by_rename(&tmp, &dest, &|| fs::write(&dest, b"other writer").unwrap()).unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&dest).unwrap(), b"other writer");
fs::remove_file(&dest).unwrap();
publish_new_by_rename(&tmp, &dest, &|| {}).unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"new");
assert!(!tmp.exists());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn atomic_write_replaces_and_leaves_no_temp() {
let d = temp("replace");
+126 -10
View File
@@ -9,6 +9,7 @@
use std::collections::{HashMap, HashSet};
use crate::query::cmp_name;
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
impl Catalog {
@@ -20,11 +21,11 @@ impl Catalog {
}
/// The stack a photo belongs to.
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
self.stacks.values().find(|s| s.photos.contains(&photo))
self.stack_id_of(photo).and_then(|id| self.stacks.get(&id))
}
/// Photo → (stack, position in the stack), for every stacked photo.
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect()
self.stacks.iter().flat_map(|(id, s)| s.photos.iter().enumerate().map(move |(i, p)| (*p, (*id, i)))).collect()
}
pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> {
@@ -40,7 +41,7 @@ impl Catalog {
return Err(CatalogError::NoPhoto(*p));
}
}
if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) {
if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) {
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
}
Ok(())
@@ -69,7 +70,7 @@ impl Catalog {
}
}
for sid in &touched {
for p in self.stacks[sid].photos.clone() {
for p in self.stacks.get(sid).map(|s| s.photos.clone()).unwrap_or_default() {
push(&mut order, p);
}
}
@@ -185,7 +186,10 @@ impl Catalog {
if !done.insert(*sid) {
continue;
}
let s = &self.stacks[sid];
let Some(s) = self.stacks.get(sid) else {
out.push(*id);
continue;
};
let members = s.photos.iter().copied().filter(|p| present.contains(p));
if s.collapsed {
out.extend(members.take(1));
@@ -203,17 +207,19 @@ impl Catalog {
/// Only runs of two or more are returned.
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
let index = self.stack_index();
let mut timed: Vec<(i64, PhotoId)> = ids
let mut timed: Vec<(i64, &str, PhotoId)> = ids
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !index.contains_key(&p.id))
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id)))
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id)))
.collect();
timed.sort();
timed.dedup_by_key(|(_, id)| *id);
// a burst within one second stays in file name order (the first file is the stack's cover)
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
// equal ids are the same photo, so same time and name: adjacent after the sort
timed.dedup_by_key(|(_, _, id)| *id);
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
let mut last: Option<i64> = None;
for (t, id) in timed {
for (t, _, id) in timed {
match (last, groups.last_mut()) {
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
_ => groups.push(vec![id]),
@@ -225,6 +231,60 @@ impl Catalog {
}
}
impl Catalog {
/// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge
/// results), then the unstacked ones in capture order, a set running while each starts within about a second of the
/// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at
/// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out.
pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec<Vec<PhotoId>> {
let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some();
let mut sets: Vec<Vec<PhotoId>> = Vec::new();
let mut stacks = HashSet::new();
let mut loose = Vec::new();
for id in ids {
match self.stack_of(*id) {
Some(s) if stacks.insert(s.id) => {
let set: Vec<PhotoId> = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect();
if set.len() >= 2 {
sets.push(set);
}
}
Some(_) => {}
None => loose.push(*id),
}
}
// (capture second, file name, id, shutter seconds, EV)
let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !merged(p))
.filter_map(|p| {
let t = iso_seconds(p.captured.as_deref()?)?;
let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?;
let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64;
let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64;
Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2()))
})
.collect();
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
timed.dedup_by_key(|x| x.2);
let mut runs: Vec<Vec<(i64, &str, PhotoId, f64, f64)>> = Vec::new();
for x in timed {
match runs.last_mut() {
Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x),
_ => runs.push(vec![x]),
}
}
for r in runs {
let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4)));
if r.len() >= 2 && hi - lo >= 0.5 {
sets.push(r.iter().map(|x| x.2).collect());
}
}
sets
}
}
/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`,
/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse.
pub fn iso_seconds(s: &str) -> Option<i64> {
@@ -264,6 +324,22 @@ mod tests {
(c, ids)
}
#[test]
fn auto_stack_keeps_a_burst_in_file_order() {
let mut c = Catalog::new();
let mut by_name = Vec::new();
for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() {
let id = PhotoId(1000 - n as u64);
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
p.captured = Some("2026-04-01T10:00:00".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
by_name.push(id);
}
let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0);
assert_eq!(groups, vec![by_name.clone()]);
assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover");
}
#[test]
fn iso_seconds_parses() {
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
@@ -336,6 +412,46 @@ mod tests {
assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id());
}
#[test]
fn bracket_sets_by_time_and_exposure() {
// (time, shutter, ISO, file name)
let shots = [
("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket
("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"),
("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"),
("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it
("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket
("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"),
("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure
("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"),
("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart
("2026-06-27T23:35:01", "2", 100, "N2.ARW"),
("2026-06-27T23:35:04", "2", 800, "N3.ARW"),
("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times
("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"),
];
let mut c = Catalog::new();
let mut ids = Vec::new();
for (t, sh, iso, name) in shots {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
p.captured = Some(t.into());
p.meta.shutter = sh.into();
p.meta.iso = Some(iso);
p.meta.aperture = Some(5.6);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
let op = c.group_ops(ids[11], &[ids[12]], true).unwrap();
c.apply(op).unwrap();
let sets = c.bracket_sets(&ids);
assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]);
// a stack's merge result on top is left out; a stack selected twice is merged once
let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap();
c.apply(op).unwrap();
assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]);
}
#[test]
fn auto_stack_by_capture_time() {
let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"];
+38 -2
View File
@@ -73,6 +73,16 @@ impl FsStore {
}
}
/// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first
/// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link
/// planted at that name into another file (issue #1502).
fn create_staging(tmp: &std::path::Path) -> io::Result<std::fs::File> {
if std::fs::symlink_metadata(tmp).is_ok() {
std::fs::remove_file(tmp)?;
}
std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)
}
impl Store for FsStore {
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
match std::fs::read(self.path(name)) {
@@ -89,7 +99,7 @@ impl Store for FsStore {
}
let tmp = self.path(&format!("{name}.tmp"));
{
let mut f = std::fs::File::create(&tmp)?;
let mut f = create_staging(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
}
@@ -105,7 +115,7 @@ impl Store for FsStore {
}
let tmp = self.path(&format!("{name}.tmp"));
let written = (|| {
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, create_staging(&tmp)?), n: 0 };
fill(&mut w)?;
w.flush()?;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
@@ -231,3 +241,29 @@ impl Store for MemStore {
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
}
}
#[cfg(test)]
mod tests_staging {
use super::*;
/// A link planted at a staging name (`<name>.tmp`) is removed, never followed: the file it points to keeps its
/// bytes and the save still lands (issue #1502).
#[cfg(unix)]
#[test]
fn a_link_at_the_staging_name_is_never_written_through() {
let dir = std::env::temp_dir().join(format!("lc-staging-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut store = FsStore::open(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"keep").unwrap();
std::os::unix::fs::symlink(&victim, dir.join("catalog.snap.tmp")).unwrap();
store.write_atomic("catalog.snap", b"{}").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
assert_eq!(std::fs::read(dir.join("catalog.snap")).unwrap(), b"{}");
std::os::unix::fs::symlink(&victim, dir.join("catalog.log.tmp")).unwrap();
store.write_atomic_with("catalog.log", &mut |w| w.write_all(b"[1]")).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]");
let _ = std::fs::remove_dir_all(&dir);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! How many photos each album shows, kept up to date from what the latest ops touched.
//!
//! A smart album's count asks every photo whether its rules hold. Asked again after every edit, that is a pass over
//! the library for each smart album each time a keyword is given. [`AlbumTally`] remembers which photos each smart
//! album holds, and after a small edit asks only the photos the edit touched ([`Catalog::touched_since`]). An op
//! that changes more than photos of its own (an album's rules or photos, a stack, the keyword list) has it counted
//! whole again, as before.
use std::collections::{HashMap, HashSet};
use crate::{AlbumId, Aspects, Catalog, PhotoId};
/// The most photos an edit may touch for the counts to follow it photo by photo.
const MAX_FOLLOWED: usize = 256;
/// Bits are kept while they cost no more than this many words a photo held, and these to start with. Past that
/// (photo ids far apart: a library whose ids don't count up from 1) a set is kept instead, so what an album
/// remembers is always in proportion to the photos it holds, never to the largest id in the library.
const WORDS_A_MEMBER: usize = 4;
const WORDS_TO_START: usize = 1024;
/// The photos a smart album holds.
#[derive(Clone, Debug)]
enum Members {
/// One bit a photo id, and how many are set.
Bits {
words: Vec<u64>,
len: usize,
},
Set(HashSet<PhotoId>),
}
impl Members {
fn none() -> Members {
Members::Bits { words: Vec::new(), len: 0 }
}
fn len(&self) -> usize {
match self {
Members::Bits { len, .. } => *len,
Members::Set(set) => set.len(),
}
}
#[cfg(test)]
fn holds(&self, id: PhotoId) -> bool {
match self {
Members::Bits { words, .. } => {
usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0)
}
Members::Set(set) => set.contains(&id),
}
}
/// Put `id` in or take it out.
fn set(&mut self, id: PhotoId, held: bool) {
if let Members::Bits { words, len } = self {
let bit = 1u64 << (id.0 % 64);
match usize::try_from(id.0 / 64) {
Ok(at) if at < words.len() => {
if let Some(word) = words.get_mut(at) {
match (*word & bit != 0, held) {
(false, true) => {
*word |= bit;
*len = len.saturating_add(1);
}
(true, false) => {
*word &= !bit;
*len = len.saturating_sub(1);
}
_ => {}
}
}
return;
}
// (past the end: not held, so there is nothing to take out)
_ if !held => return,
// room for it, as long as the bits stay in proportion to the photos held
Ok(at) if at < len.saturating_mul(WORDS_A_MEMBER).saturating_add(WORDS_TO_START) => {
words.resize(at + 1, 0);
if let Some(word) = words.get_mut(at) {
*word |= bit;
*len = len.saturating_add(1);
}
return;
}
// ids too far apart for bits: a set from here on
_ => {
let ids = words
.iter()
.enumerate()
.flat_map(|(at, word)| (0..64u64).filter(move |b| word & (1u64 << b) != 0).map(move |b| PhotoId(at as u64 * 64 + b)));
*self = Members::Set(ids.collect());
}
}
}
if let Members::Set(set) = self {
if held {
set.insert(id);
} else {
set.remove(&id);
}
}
}
}
/// [`Catalog::album_count`] of every album, as of one revision of the catalog.
#[derive(Clone, Debug)]
pub struct AlbumTally {
revision: u64,
counts: HashMap<AlbumId, usize>,
/// The smart albums' photos.
members: HashMap<AlbumId, Members>,
}
impl AlbumTally {
/// Counted from every photo.
pub fn of(cat: &Catalog) -> AlbumTally {
let mut counts = HashMap::new();
let mut members = HashMap::new();
for album in cat.albums() {
if album.is_smart() {
let mut held = Members::none();
for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) {
held.set(p.id, true);
}
counts.insert(album.id, held.len());
members.insert(album.id, held);
} else {
counts.insert(album.id, cat.album_count(album.id));
}
}
AlbumTally { revision: cat.revision, counts, members }
}
/// The count of each album.
pub fn counts(&self) -> &HashMap<AlbumId, usize> {
&self.counts
}
/// The catalog's revision the counts are of.
pub fn revision(&self) -> u64 {
self.revision
}
/// Bring the counts up to `cat` as it is now. `true` when that was done from the photos the ops since touched
/// (or nothing had changed), `false` when every photo was asked again.
///
/// `cat` has to be the catalog the counts were made of, further along. Another catalog is told apart only by
/// its revisions: a library that is loaded takes a range of its own (and then everything is counted again), but
/// two catalogs both counting from 0 look like one.
pub fn update(&mut self, cat: &Catalog) -> bool {
if self.revision == cat.revision {
return true;
}
let followed = match (cat.touched_since(self.revision), cat.aspects_since(self.revision)) {
(Some(touched), Some(changed)) if touched.len() <= MAX_FOLLOWED => {
self.follow(cat, &touched, changed);
true
}
_ => false,
};
if !followed {
*self = AlbumTally::of(cat);
}
followed
}
/// The counts after ops that touched only `touched`: each smart album asks those photos again; an album of
/// chosen photos counts again only when a photo came, went or was deleted (who is in it did not change: that
/// is an op on the album, and not followed).
fn follow(&mut self, cat: &Catalog, touched: &[PhotoId], changed: Aspects) {
for (album, held) in &mut self.members {
for id in touched {
held.set(*id, cat.photo(*id).is_some_and(|p| cat.album_contains(*album, p)));
}
self.counts.insert(*album, held.len());
}
if changed.any_of(Aspects::PRESENCE) {
for album in cat.albums().filter(|a| !a.is_smart()) {
self.counts.insert(album.id, cat.album_count(album.id));
}
}
self.revision = cat.revision;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(m: &Members, of: &[u64]) -> Vec<u64> {
of.iter().copied().filter(|id| m.holds(PhotoId(*id))).collect()
}
#[test]
fn members_are_kept_as_bits_at_the_edges_of_a_word_and_past_the_end() {
let mut m = Members::none();
let probe = [0, 1, 63, 64, 65, 127, 128, 5000];
assert!(ids(&m, &probe).is_empty() && m.len() == 0);
for id in [0, 63, 64, 5000] {
m.set(PhotoId(id), true);
}
assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 64, 5000], 4));
// put in twice, taken out twice, taken out where nothing is, far past the end: counted once each way
m.set(PhotoId(63), true);
m.set(PhotoId(64), false);
m.set(PhotoId(64), false);
m.set(PhotoId(1), false);
m.set(PhotoId(900_000), false);
assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 5000], 3));
assert!(matches!(m, Members::Bits { .. }));
}
#[test]
fn photo_ids_far_apart_are_kept_as_a_set_in_proportion_to_the_photos_held() {
// one photo with an id in the thousands of millions: no bit for every id up to it
let mut m = Members::none();
for id in [3, 70, 9_000_000_000] {
m.set(PhotoId(id), true);
}
assert!(matches!(m, Members::Set(_)), "a set once the ids are too far apart for bits");
assert_eq!((ids(&m, &[3, 70, 71, 9_000_000_000, u64::MAX]), m.len()), (vec![3, 70, 9_000_000_000], 3));
m.set(PhotoId(u64::MAX), true);
m.set(PhotoId(3), false);
assert_eq!((ids(&m, &[3, 70, 9_000_000_000, u64::MAX]), m.len()), (vec![70, 9_000_000_000, u64::MAX], 3));
// an album of many photos with ids counting up stays bits, however many
let mut dense = Members::none();
for id in 1..=200_000u64 {
dense.set(PhotoId(id), true);
}
assert!(matches!(&dense, Members::Bits { words, .. } if words.len() <= 200_000 / 64 + 1));
assert_eq!(dense.len(), 200_000);
}
}
+179 -2
View File
@@ -123,6 +123,23 @@ fn filter_search_sort() {
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
/// the rest instead of panicking on a char boundary.
#[test]
fn date_groups_ignores_a_non_ascii_captured() {
let mut c = Catalog::new();
let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00");
// `AddPhoto` takes the field as it is, like `library.importLightroom` building the op
let bad = photo(&mut c, "bad.jpg", "日本語日本語");
assert_ne!(ok, bad);
let g = c.date_groups();
assert_eq!(g.len(), 1, "the one well-formed date still groups");
assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1));
assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 1);
}
#[test]
fn snapshot_and_log_replay() {
let mut c = Catalog::new();
@@ -265,13 +282,30 @@ fn preview_only_survives_serde_and_set_content_undo() {
let old: Catalog = serde_json::from_value(v).unwrap();
assert_eq!(old.photo(a).unwrap().preview_only, None);
assert!(old.photo(a).unwrap().develops_raw());
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
// a newer build still can't decode it: the reason and the stamp of the version that decided
// it are recorded together (#662)
let op = Op::SetContent {
id: a,
width: 6000,
height: 4000,
file_size: 1,
content_hash: None,
preview_only: Some("Nikon Huffman-compressed NEF".into()),
preview_checked: "0.6.0".into(),
};
c.apply(op).unwrap();
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
// Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON)
// restores both
let op =
Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() };
let inv = c.apply(op).unwrap();
assert!(c.photo(a).unwrap().develops_raw());
assert_eq!(c.photo(a).unwrap().preview_checked, "");
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
assert_eq!(c.photo(a).unwrap().width, 6000);
}
@@ -391,6 +425,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() {
assert_eq!(back, with);
}
/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a
/// catalog from before it existed reads with none, and writes the same), kept exactly when known.
#[test]
fn focus_point_is_not_serialized_when_unknown_and_round_trips() {
let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() };
let v = serde_json::to_value(&m).unwrap();
assert!(v.get("focus").is_none(), "{v}");
let back: Meta = serde_json::from_value(v).unwrap();
assert!(back.focus.is_none());
let mut with = m.clone();
with.focus = Some(lightcraft_meta::FocusPoint {
rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 },
mode: Some("AF-C".into()),
area: Some("Wide".into()),
tracking: None,
});
let json = serde_json::to_string(&with).unwrap();
assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}");
let back: Meta = serde_json::from_str(&json).unwrap();
assert_eq!(back, with);
}
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
@@ -694,3 +750,124 @@ fn undated_photos_group_under_unknown_date_and_sort_together() {
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
}
/// Photos with one import time and capture time, as one import batch or a burst gives them, added
/// with ids that run opposite to their names.
fn batch_with_reversed_ids(c: &mut Catalog) -> Vec<PhotoId> {
let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"];
let mut ids = Vec::new();
for (n, name) in names.iter().enumerate() {
let id = PhotoId(1000 - n as u64);
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00");
p.captured = Some("2026-10-01T12:00:00".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
ids // in name order
}
#[test]
fn one_import_batch_sorts_by_file_name_not_id() {
let mut c = Catalog::new();
let by_name = batch_with_reversed_ids(&mut c);
let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() };
assert_eq!(c.query(&Filter::default(), &sort), by_name);
}
#[test]
fn burst_in_one_second_sorts_by_file_name_not_id() {
let mut c = Catalog::new();
let by_name = batch_with_reversed_ids(&mut c);
let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() };
assert_eq!(c.query(&Filter::default(), &sort), by_name);
}
#[test]
fn cmp_name_ignores_case_without_allocating() {
use crate::query::cmp_name;
use std::cmp::Ordering::*;
assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal);
assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal);
assert_eq!(cmp_name("a", "B"), Less);
assert_eq!(cmp_name("B", "a"), Greater);
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
assert_eq!(cmp_name("a", "ab"), Less);
}
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
/// said photo by photo.
#[test]
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
let mut c = Catalog::default();
let photo = |c: &mut Catalog, name: &str| {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
})
.unwrap();
id
};
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
let start = c.revision;
assert_eq!(c.touched_since(start), Some(vec![]));
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
// a rating, then a flag on another photo, as one batch
c.apply(Op::SetRating { id: a, rating: 3 }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a]));
assert_eq!(c.aspects_since(start), Some(Aspects::RATING));
let after_rating = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap();
assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once");
assert_eq!(c.touched_since(after_rating), Some(vec![a, b]));
assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING)));
assert_eq!(c.touched_since(c.revision), Some(vec![]));
// a photo that goes counts for everything
let before = c.revision;
c.apply(Op::RemovePhoto { id: b }).unwrap();
assert_eq!(c.touched_since(before), Some(vec![b]));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE)));
// an op that changes more than photos of its own: nothing is said of what came before it
let before = c.revision;
let album = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
assert_eq!(c.touched_since(before), None);
assert_eq!(c.aspects_since(start), None);
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
// a revision that is not this catalog's past
assert_eq!(c.touched_since(c.revision + 5), None);
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
// the next op nothing of before it
let old = c.revision;
c.revision = 7 << 32;
assert_eq!(c.touched_since(old), None);
assert_eq!(c.aspects_since(c.revision), None);
let loaded = c.revision;
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
assert_eq!(c.touched_since(loaded), Some(vec![a]));
assert_eq!(c.touched_since(old), None);
// an op that fails changes nothing, and what is remembered never says less than what happened
let before = c.revision;
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
assert_eq!(c.revision, before);
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
// with an op that changes more than photos is not
let before = c.revision;
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap();
// (the photo the failed batch asked for and never found may be named too: more is said, never less)
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
let before = c.revision;
c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap();
assert_eq!(c.touched_since(before), None);
// more ops than are remembered: the oldest are answered with "anything"
let long_ago = c.revision;
for i in 0..9000u32 {
c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap();
}
assert_eq!(c.touched_since(long_ago), None);
assert_eq!(c.touched_since(c.revision - 10), Some(vec![a]));
}
+17
View File
@@ -237,6 +237,23 @@ fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
assert!(!f.matches(c.photo(kept).unwrap(), &c));
}
#[test]
fn a_deleted_browsed_photo_shows_in_recently_deleted_and_comes_back() {
let mut c = Catalog::new();
let browsed = add_with(&mut c, "/pics/trip/a.jpg", |p| p.local = true);
let recently_deleted = Filter { deleted: true, ..Default::default() };
let library = Filter::default();
assert!(!recently_deleted.matches(c.photo(browsed).unwrap(), &c));
assert!(!library.matches(c.photo(browsed).unwrap(), &c), "browsed photos stay out of the library view");
c.apply(Op::SetDeleted { id: browsed, deleted: true }).unwrap();
assert_eq!(c.query(&recently_deleted, &Sort::default()), vec![browsed], "#1430: it was invisible everywhere");
assert!(c.query(&library, &Sort::default()).is_empty());
c.apply(Op::SetDeleted { id: browsed, deleted: false }).unwrap();
assert!(c.query(&recently_deleted, &Sort::default()).is_empty());
}
#[test]
fn an_empty_folder_choice_is_no_choice() {
let c = library(&["/pics/a.jpg"]);
+30 -3
View File
@@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() {
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with
/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the
/// current format.
#[test]
fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() {
let mut c = Catalog::new();
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01");
p.kind = MediaKind::Raw;
p.preview_only = Some("Cr3 files are not decoded yet".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let snap = c.to_snapshot();
assert!(!snap.contains("preview_checked"), "an empty stamp isn't written");
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
let p = loaded.photo(id).unwrap();
assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet"));
assert_eq!(p.preview_checked, "");
assert_eq!(r.upgraded_from, Some(6));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
#[test]
fn versionless_log_only_library_is_upgraded() {
let mut c = Catalog::new();
@@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() {
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
/// variant here under the new version.
/// variant here under the new version. A version that only adds serialized fields raises
/// `NEWEST_FIELDS` instead.
#[test]
fn op_variants_are_versioned() {
fn since(op: &Op) -> u32 {
@@ -211,8 +235,11 @@ fn op_variants_are_versioned() {
Op::SetKeyword { .. } => 5,
Op::SetFolderRecord { .. } => 6,
Op::SetAsShotWb { .. } => 7,
Op::SetHistoryCursor { .. } => 8,
}
}
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");
/// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.
const NEWEST_FIELDS: u32 = 9;
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version");
}
+211
View File
@@ -0,0 +1,211 @@
//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op,
//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same
//! results as before.
use proptest::prelude::*;
use super::*;
const PHOTOS: u64 = 8;
fn library() -> Catalog {
let mut c = Catalog::new();
for i in 0..PHOTOS {
let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
c
}
/// What the indexes must say, worked out by scanning (as the catalog did before it had them).
fn scanned(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect();
let albums = (1..=4)
.map(|a| {
photos
.iter()
.map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p)))
.collect()
})
.collect();
(stacks, albums)
}
/// What the indexes say.
fn indexed(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect();
let albums = (1..=4)
.map(|a| {
photos
.iter()
.map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph)))
.collect()
})
.collect();
(stacks, albums)
}
/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail).
fn ids(bits: u16) -> Vec<PhotoId> {
(0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect()
}
fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op {
match kind % 8 {
0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } },
1 => Op::RemoveStack { id: StackId(n % 4 + 1) },
2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed },
3 => {
let mut a = Album::new(AlbumId(n % 4 + 1), "A");
a.photos = ids(bits);
Op::AddAlbum { album: a }
}
4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) },
5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
// a batch whose last op fails half the time: what it did is rolled back
6 => Op::Batch {
ops: vec![
Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed },
Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } },
],
},
// as the engine deletes a photo: out of its albums and stacks too, in one batch
_ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::<bool>()), 1..40)) {
let mut c = library();
let start = c.to_snapshot();
// built before the ops, so every op has to keep them up to date
prop_assert_eq!(indexed(&c), scanned(&c));
let mut log = String::new();
let mut inverses = Vec::new();
for (kind, n, bits, collapsed) in steps {
let op = op(&c, kind, n, bits, collapsed);
if let Ok(inv) = c.apply(op.clone()) {
log.push_str(&Catalog::op_to_log_line(&op));
inverses.push(inv);
}
prop_assert_eq!(indexed(&c), scanned(&c));
}
// a snapshot, a replayed journal and a clone build their own
let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
prop_assert_eq!(indexed(&snap), scanned(&c));
let mut replayed = Catalog::from_snapshot(&start).unwrap();
prop_assert_eq!(indexed(&replayed), scanned(&replayed));
replayed.replay(&log).unwrap();
prop_assert_eq!(indexed(&replayed), scanned(&c));
prop_assert_eq!(indexed(&c.clone()), scanned(&c));
prop_assert!(c.clone() == c, "the indexes don't take part in equality");
// undone step by step
for inv in inverses.into_iter().rev() {
c.apply(inv).unwrap();
prop_assert_eq!(indexed(&c), scanned(&c));
}
prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone");
}
}
#[test]
fn a_photo_in_another_stack_is_refused_by_the_index() {
let mut c = library();
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err();
assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into()));
// its own stack may keep it
c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap();
assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1)));
}
/// The sort keys are worked out once per photo now; the order is the one the comparisons gave.
#[test]
fn sorted_queries_keep_their_order() {
let mut c = Catalog::new();
let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"];
for (i, n) in names.iter().enumerate() {
let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3));
p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4));
p.rating = (i % 3) as u8;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let all = Filter::default();
for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random]
{
for ascending in [true, false] {
let sort = Sort { key, ascending, seed: 7, ..Default::default() };
// the comparison the query sorts by
let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect();
want.sort_by(|a, b| sort.order(a, b));
let want: Vec<PhotoId> = want.iter().map(|p| p.id).collect();
assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}");
}
}
// `only` as a set gives the photos it names
let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() };
assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]);
}
/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave).
#[test]
fn rule_names_are_the_variants_in_lower_case() {
let mut c = Catalog::new();
let mut n = 0;
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
for label in
[None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)]
{
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
n += 1;
let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00");
(p.flag, p.label, p.kind) = (flag, label, kind);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
}
}
c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap();
let count = |field: &str, value: String| {
let rules: RuleSet =
serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap();
c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len()
};
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}");
}
assert_eq!(count("flag", "picked".into()), 18);
for label in ColorLabel::ALL {
assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}");
}
assert_eq!(count("label", "sky".into()), 9, "a custom name");
assert_eq!(count("label", "none".into()), 9);
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}");
}
}
/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo
/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before
/// and after either stack goes.
#[test]
fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() {
let mut c = library();
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap();
let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap();
v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]);
let mut c = Catalog::from_snapshot(&v.to_string()).unwrap();
assert_eq!(indexed(&c), scanned(&c));
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1)));
// the second stack can't be changed to keep it, as before
assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err());
c.apply(Op::RemoveStack { id: StackId(1) }).unwrap();
assert_eq!(indexed(&c), scanned(&c));
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack");
}
+16 -10
View File
@@ -126,34 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
/// 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.
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a
/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few
/// children have been spawned during them, at most a minute.
#[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};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// Children spawned while the lock cycles, at least, before the test may stop.
const CHILDREN: usize = 5;
let dir = temp_dir("spawn");
let stop = Arc::new(AtomicBool::new(false));
let count = Arc::new(AtomicUsize::new(0));
let spawner = {
let stop = Arc::clone(&stop);
let (stop, count) = (Arc::clone(&stop), Arc::clone(&count));
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()
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
count.fetch_add(1, Ordering::Relaxed);
}
}
spawned
count.load(Ordering::Relaxed)
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let t0 = Instant::now();
let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN;
let mut cycles = 0usize;
let mut refused = Vec::new();
while cycles < 200_000 && std::time::Instant::now() < deadline {
while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) {
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:?}")),
+152
View File
@@ -0,0 +1,152 @@
//! A damaged or hand-edited snapshot that still parses is made to hold together on load
//! ([`Catalog::repair`]): found by fuzzing snapshots of a real library.
use crate::journal::SNAPSHOT;
use crate::*;
fn album(id: u64, parent: Option<u64>, folder: bool, photos: Vec<PhotoId>) -> Album {
Album {
id: AlbumId(id),
name: format!("A{id}"),
parent: parent.map(AlbumId),
folder,
photos,
cover: None,
smart: None,
quick: false,
order: None,
}
}
/// A catalog of four photos, a stack of two, a folder with an album in it.
fn sound() -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for i in 0..4 {
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{i}.jpg") }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01")),
})
.unwrap();
ids.push(id);
}
let sid = c.alloc_stack_id();
c.apply(Op::AddStack { stack: Stack { id: sid, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap();
let (f, a) = (c.alloc_album_id(), c.alloc_album_id());
c.apply(Op::AddAlbum { album: album(f.0, None, true, vec![]) }).unwrap();
c.apply(Op::AddAlbum { album: album(a.0, Some(f.0), false, vec![ids[2]]) }).unwrap();
(c, ids)
}
/// The snapshot file of `c` with `edit` applied to its JSON, loaded through the journal.
fn load_edited(c: &Catalog, edit: impl FnOnce(&mut serde_json::Value)) -> (Catalog, LoadReport) {
let m = MemStore::new();
let (mut j, _, _) = Journal::open(Box::new(m.clone())).unwrap();
j.snapshot(c).unwrap();
let mut v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
edit(&mut v["catalog"]);
m.set(SNAPSHOT, serde_json::to_vec(&v).unwrap());
drop(j);
let (_, c, report) = Journal::open(Box::new(m)).unwrap();
(c, report)
}
/// A stack whose `id` differs from the key it is filed under made `arrange_stacks` index the map
/// with an id it doesn't hold, which panics: the grid of any library with such a stack.
#[test]
fn a_stack_filed_under_another_id_is_arranged() {
let (c, ids) = sound();
let (c, report) = load_edited(&c, |v| {
let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
v["stacks"][&key]["id"] = 77.into();
});
assert!(report.repaired.iter().any(|r| r.contains("stack 77 was filed as")), "{:?}", report.repaired);
assert_eq!(c.arrange_stacks(&ids), vec![ids[0], ids[2], ids[3]], "the collapsed stack shows its top");
assert!(c.stacks().all(|s| c.stack_of(s.photos[0]).is_some_and(|t| t.id == s.id)));
}
/// Stacks of missing, repeated or too few photos (or one photo in two stacks) are put right: a
/// stack of none had no top (`photos[0]`).
#[test]
fn stacks_keep_only_photos_in_the_catalog() {
let (c, ids) = sound();
let (c, report) = load_edited(&c, |v| {
let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
v["stacks"][&key]["photos"] = serde_json::json!([ids[0].0, 999, ids[0].0, ids[1].0]);
v["stacks"]["8"] = serde_json::json!({"id": 8, "photos": []});
v["stacks"]["9"] = serde_json::json!({"id": 9, "photos": [ids[1].0, ids[3].0, ids[2].0]});
});
let stacks: Vec<(StackId, Vec<PhotoId>)> = c.stacks().map(|s| (s.id, s.photos.clone())).collect();
assert_eq!(stacks.len(), 2, "{stacks:?} {:?}", report.repaired);
assert_eq!(stacks[0].1, vec![ids[0], ids[1]], "missing and repeated photos left out");
assert_eq!(stacks[1].1, vec![ids[3], ids[2]], "a photo already in a stack stays in the first");
assert!(!c.arrange_stacks(&ids).is_empty());
}
/// Folders inside each other (a loop), or under an album or a missing folder, move to the top
/// level: walking up from them never ends otherwise, and moving any folder there hung.
#[test]
fn folder_loops_and_missing_parents_move_to_the_top() {
let (c, _) = sound();
let (mut c, report) = load_edited(&c, |v| {
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), true, vec![])).unwrap();
v["albums"]["11"] = serde_json::to_value(album(11, Some(10), true, vec![])).unwrap();
v["albums"]["12"] = serde_json::to_value(album(12, Some(12), true, vec![])).unwrap();
v["albums"]["13"] = serde_json::to_value(album(13, Some(404), false, vec![])).unwrap();
});
assert!(!report.repaired.is_empty());
for id in [10, 11, 12, 13] {
let a = c.album(AlbumId(id)).unwrap();
assert!(
a.parent.is_none() || a.parent.is_some_and(|p| c.album(p).is_some_and(|f| f.folder && f.parent.is_none())),
"album {id}: {:?}",
a.parent
);
}
// moving a folder around terminates either way
let _ = c.apply(Op::MoveAlbum { id: AlbumId(10), parent: Some(AlbumId(11)) });
let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) });
}
/// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer
/// overflows (a panic in debug builds) and never hands out an id already in use.
#[test]
fn id_counters_are_moved_past_the_ids_in_use() {
let (c, ids) = sound();
let (mut c, report) = load_edited(&c, |v| {
v["next_photo"] = 1.into();
v["next_album"] = 1.into();
v["next_stack"] = 1.into();
});
assert!(report.repaired.iter().any(|r| r.contains("id counters")), "{:?}", report.repaired);
assert!(c.alloc_photo_id().0 > ids[3].0);
let (mut c, _) = load_edited(&c, |v| v["next_photo"] = u64::MAX.into());
assert_eq!((c.alloc_photo_id().0, c.alloc_photo_id().0), (u64::MAX, u64::MAX), "saturates");
let p = Photo::new(PhotoId(u64::MAX), Source::Demo { scene: 1 }, "a", "JPEG", 1, 1, "t");
c.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
assert!(c.apply(Op::AddPhoto { photo: Box::new(p) }).is_err(), "the id is taken: refused, not overwritten");
}
/// A sound catalog loads unchanged.
#[test]
fn a_sound_snapshot_needs_no_repair() {
let (c, _) = sound();
let (loaded, report) = load_edited(&c, |_| {});
assert!(report.repaired.is_empty(), "{:?}", report.repaired);
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
}
/// Found by fuzzing: an album filed under one id but naming another (here: the folder's) put
/// itself inside itself, and listing the folder tree overflowed the stack (an abort, which no
/// panic guard catches, every time the library opened).
#[test]
fn an_album_filed_under_another_id_gets_its_own() {
let (c, _) = sound();
let folder = c.albums().find(|a| a.folder).unwrap().id;
let inner = c.albums().find(|a| !a.folder).unwrap().id;
let (c, report) = load_edited(&c, |v| v["albums"][inner.0.to_string()]["id"] = folder.0.into());
assert!(report.repaired.iter().any(|r| r.contains(&format!("album {} was filed as {}", folder.0, inner.0))), "{:?}", report.repaired);
let a = c.album(inner).unwrap();
assert_eq!((a.id, a.parent), (inner, Some(folder)));
assert_eq!(c.album_children(Some(folder)).iter().map(|a| a.id).collect::<Vec<_>>(), vec![inner]);
}
+92
View File
@@ -0,0 +1,92 @@
//! What library queries cost at 100k photos (ignored:
//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`).
use std::time::Instant;
use serde_json::json;
use super::*;
/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags.
fn library(n: u64) -> (Catalog, AlbumId) {
let mut c = Catalog::new();
let mut ops: Vec<Op> = (0..n)
.map(|i| {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{:06}.jpg", mix64(i) % 1_000_000),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
p.rating = (i % 6) as u8;
p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None };
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
ops.extend(
(0..n / 10)
.map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }),
);
let album = AlbumId(1);
let mut a = Album::new(album, "Big");
a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect();
ops.push(Op::AddAlbum { album: a });
let t = Instant::now();
c.apply(Op::Batch { ops }).unwrap();
eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3);
(c, album)
}
fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId {
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() }));
c.apply(Op::AddAlbum { album: a }).unwrap();
AlbumId(id)
}
/// Best of five runs (the machine may be busy with other work).
fn time<T>(what: &str, f: impl Fn() -> T) -> T {
let mut best = f64::MAX;
let mut r = None;
for _ in 0..5 {
let t = Instant::now();
r = Some(f());
best = best.min(t.elapsed().as_secs_f64() * 1e3);
}
eprintln!("{what}: {best:.1} ms");
r.unwrap()
}
#[test]
#[ignore]
fn scale_100k() {
let (mut c, album) = library(100_000);
let all = Filter::default();
time("query, capture date", || c.query(&all, &Sort::default()));
time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() }));
time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default()));
time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default()));
let only: Vec<PhotoId> = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect();
time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default()));
let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]}));
let flagged = smart(
&mut c,
11,
json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}),
);
let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]}));
let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]}));
let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]}));
for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] {
let n = time(&format!("smart album count, {what}"), || c.album_count(a));
eprintln!(" = {n}");
}
time("albums_of", || c.albums_of(PhotoId(6)));
time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default())));
time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count());
}
+2
View File
@@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
# JPEG XL tests read encoded samples without our colour conversion
jxl-oxide = { workspace = true }
proptest = { workspace = true }
+2 -2
View File
@@ -11,7 +11,7 @@ let fmt: Option<Format> = sniff(&bytes); // JPEG/PNG/TIFF/WebP/A
let d: Decoded = decode(&bytes, DecodeOptions::default())?;
// d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace)
// d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs)
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford), alpha flattened onto white
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
@@ -53,7 +53,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| 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) | 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`) |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) |
| 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 |
+85 -9
View File
@@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 {
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
}
/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HdrTransfer {
#[default]
Pq,
Hlg,
}
/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408).
pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0;
/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100:
/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma
/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75,
/// as BT.2408 asks. Non-finite or negative inputs count as 0.
pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] {
const K: [f32; 3] = [0.2627, 0.6780, 0.0593];
const GAMMA: f32 = 1.2;
let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS));
let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0);
let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 };
fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32)
}
/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]).
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq)
}
/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level
/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display).
/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> 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) {
@@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += y_nits as f64;
let [r, g, b] = nits.map(pq_encode);
let [r, g, b] = match transfer {
HdrTransfer::Pq => nits.map(pq_encode),
HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]),
};
let y = K[0] * r + K[1] * g + K[2] * b;
let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
@@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
ec.pixel_range = PixelRange::Full;
ec.color_description = Some(ColorDescription {
color_primaries: ColorPrimaries::BT2020,
transfer_characteristics: TransferCharacteristics::SMPTE2084,
transfer_characteristics: match transfer {
HdrTransfer::Pq => TransferCharacteristics::SMPTE2084,
HdrTransfer::Hlg => TransferCharacteristics::HLG,
},
matrix_coefficients: MatrixCoefficients::BT2020NCL,
});
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
ec.content_light =
(transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
ec.still_picture = true;
ec.quantizer = quantizer;
ec.min_quantizer = quantizer.min(255) as u8;
@@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
use avif_serialize::constants as c;
let mut a = avif_serialize::Aviffy::new();
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
.set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
.set_transfer_characteristics(match transfer {
HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084,
HdrTransfer::Hlg => c::TransferCharacteristics::Hlg,
})
.set_color_primaries(c::ColorPrimaries::Bt2020)
.set_full_color_range(true)
.set_content_light_level(cll.0, cll.1);
.set_full_color_range(true);
if transfer == HdrTransfer::Pq {
a.set_content_light_level(cll.0, cll.1);
}
if let Some(exif) = meta.exif {
a.set_exif(exif.to_vec());
}
@@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
}
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
{
let _ = (quality, speed, meta);
let _ = (quality, speed, meta, transfer);
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}
@@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<
.map_err(|e| Error::Encode(e.to_string()))?;
Ok(r.avif_file)
}
#[cfg(test)]
mod hlg_tests {
use super::*;
#[test]
fn hlg_reference_white_and_peak() {
// BT.2408: SDR (HDR reference) white sits at 75 % HLG signal
for v in hlg_signal([1.0; 3]) {
assert!((v - 0.75).abs() < 0.005, "{v}");
}
// the 1000 cd/m² peak (≈ 4.9× SDR white) reaches full signal; above it clips
let peak = HLG_REFERENCE_PEAK_NITS / HDR_REFERENCE_WHITE_NITS;
assert!(hlg_signal([peak; 3]).iter().all(|v| (v - 1.0).abs() < 1e-3));
assert_eq!(hlg_signal([100.0; 3]), [1.0; 3]);
// black, negatives and non-finite values are 0, never NaN
assert_eq!(hlg_signal([0.0, -1.0, f32::NAN]), [0.0; 3]);
assert!(hlg_signal([f32::INFINITY, 0.0, 0.0]).iter().all(|v| v.is_finite()));
// monotonic in brightness
let mut prev = 0.0;
for i in 1..50 {
let v = hlg_signal([i as f32 * 0.1; 3])[1];
assert!(v >= prev);
prev = v;
}
}
#[test]
fn hdr_avif_rejects_short_buffers() {
let m = EncodeMeta::default();
assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err());
assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err());
}
}
+116 -15
View File
@@ -75,14 +75,12 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
let end = (p + 2 + len).min(b.len());
let seg = &b[start..end];
match marker {
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
if seg.len() >= 6 && m.components == 0 {
m.precision = seg[0];
m.sof = marker;
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
m.components = seg[5];
}
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => {
m.precision = seg[0];
m.sof = marker;
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
m.components = seg[5];
}
0xE1 => {
if seg.starts_with(b"Exif\0\0") && m.exif.is_none() {
@@ -182,9 +180,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
check_size(F, m.width as u64, m.height as u64, opts)?;
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
// 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling)
// and every lossless (SOF3) file, which zune-jpeg refuses outright.
let lossless = m.sof == 0xC3;
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved {
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless {
decode_jpeg_decoder(bytes, &m, scale_to)?
} else {
decode_zune(bytes, &m)?
@@ -221,7 +221,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
use jpeg_decoder::PixelFormat;
let mut d = jpeg_decoder::Decoder::new(bytes);
d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?;
if let Some((mw, mh)) = scale_to {
// `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the
// output to the top-left of each plane. Those decode at full size; `finish` scales after.
if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) {
// Ask for the smallest DCT scale still covering the target box.
let (sw, sh) = scaled_request(m.width, m.height, mw, mh);
d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?;
@@ -231,16 +233,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
let (w, h) = (info.width as usize, info.height as usize);
let (model, buf, depth) = match info.pixel_format {
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
PixelFormat::L16 => {
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(Model::Gray, Buf::U16(v), 16)
}
PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16),
// A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its
// buffer holds two native-endian bytes per sample like L16.
PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16),
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16),
PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8),
};
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw
/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads,
/// as every 16-bit decoder feeding [`Buf::U16`] does.
fn lossless_samples(px: &[u8], precision: u8) -> Vec<u16> {
let shift = 16u8.saturating_sub(precision);
px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect()
}
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
/// is most of [`header`]'s time).
fn find_ff(b: &[u8]) -> Option<usize> {
@@ -450,4 +461,94 @@ mod tests {
let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap();
assert!(m.exif.is_none());
}
// --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so
// every pixel is a zero difference and decodes to the predictor's initial sample. No such
// encoder exists here; the bytes are the smallest a decoder accepts.
fn lossless_header(out: &mut Vec<u8>, components: u8, w: u16, precision: u8) {
// DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol.
out.extend_from_slice(&[0xFF, 0xC4]);
out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes());
out.push(0x00);
let mut counts = [0u8; 16];
counts[0] = 1;
out.extend_from_slice(&counts);
out.push(0x00);
// SOF3
out.extend_from_slice(&[0xFF, 0xC3]);
out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes());
out.push(precision);
out.extend_from_slice(&1u16.to_be_bytes());
out.extend_from_slice(&w.to_be_bytes());
out.push(components);
for c in 1..=components {
out.extend_from_slice(&[c, 0x11, 0]);
}
// SOS: predictor Ra, Se = 0, Ah/Al = 0
out.extend_from_slice(&[0xFF, 0xDA]);
out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes());
out.push(components);
for c in 1..=components {
out.extend_from_slice(&[c, 0]);
}
out.extend_from_slice(&[1, 0, 0]);
}
/// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial
/// prediction `1 << (precision - 1)`.
fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec<u8> {
let mut out = vec![0xFF, 0xD8];
lossless_header(&mut out, components, w, precision);
// entropy: one zero bit per sample (a category-0 code), padded to whole bytes
out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]);
out.extend_from_slice(&[0xFF, 0xD9]);
out
}
/// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw
/// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the
/// raw value was never left-justified into the 16-bit buffer.
#[test]
fn lossless_grayscale_samples_are_native_endian_and_left_justified() {
let bytes = lossless_jpeg(1, 8, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified");
assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16));
}
/// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per
/// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows.
#[test]
fn lossless_high_precision_rgb_is_16_bit() {
let bytes = lossless_jpeg(3, 2, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 6]);
assert_eq!(raw.model, Model::Rgb);
}
/// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample.
#[test]
fn lossless_high_precision_cmyk_is_16_bit() {
let bytes = lossless_jpeg(4, 2, 12);
let m = parse_markers(&bytes).unwrap();
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
assert_eq!(v, &vec![2048u16 << 4; 8]);
assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16));
}
/// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to
/// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable.
#[test]
fn eight_bit_lossless_jpeg_decodes() {
let bytes = lossless_jpeg(1, 8, 8);
let d = crate::decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (8, 1));
assert_eq!(d.bit_depth, 8);
}
}
+285 -34
View File
@@ -3,8 +3,11 @@
//! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC
//! prediction restarts at every marker, so bands are entropy-coded independently on all cores and
//! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100
//! quality convention; Huffman coding uses the Annex K example tables (interleaved baseline, readable
//! by every decoder). Supports grayscale and YCbCr 4:4:4 / 4:2:0.
//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81
//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays
//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is
//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr
//! 4:4:4 / 4:2:0.
use crate::encode::ChromaSubsampling;
@@ -61,16 +64,103 @@ impl Huff {
let (mut c, mut k) = (0u16, 0usize);
for (len, &n) in bits.iter().enumerate() {
for _ in 0..n {
code[vals[k] as usize] = (c, len as u8 + 1);
c += 1;
if let Some(&v) = vals.get(k) {
code[v as usize] = (c, len as u8 + 1);
}
c = c.wrapping_add(1);
k += 1;
}
c <<= 1;
c = c.wrapping_shl(1);
}
Huff { code }
}
}
/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte).
type Freq = [u64; 256];
/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal
/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code
/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol
/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`.
fn optimal_table(freq: &Freq) -> ([u8; 16], Vec<u8>) {
// 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is
// then dropped so no real code is all ones (K.2)
let mut f = [0u64; 257];
f[..256].copy_from_slice(freq);
if f[..256].iter().all(|&c| c == 0) {
f[0] = 1;
}
f[256] = 1;
let mut size = [0usize; 257];
let mut next: [Option<usize>; 257] = [None; 257];
loop {
// the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1)
let mut c1: Option<usize> = None;
for i in 0..257 {
if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) {
c1 = Some(i);
}
}
let Some(c1) = c1 else { break };
let mut c2: Option<usize> = None;
for i in 0..257 {
if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) {
c2 = Some(i);
}
}
let Some(c2) = c2 else { break };
f[c1] = f[c1].saturating_add(f[c2]);
f[c2] = 0;
// every symbol in both trees goes one level deeper; c2's chain is appended to c1's
let mut k = c1;
size[k] += 1;
while let Some(n) = next[k] {
k = n;
size[k] += 1;
}
next[k] = Some(c2);
let mut k = c2;
size[k] += 1;
while let Some(n) = next[k] {
k = n;
size[k] += 1;
}
}
// counts per code length; 257 symbols need at most 256 levels
let mut bits = [0usize; 257];
for &s in &size {
if s > 0 {
bits[s.min(256)] += 1;
}
}
// limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the
// deepest level of a full tree always holds an even number of codes)
for i in (17..257).rev() {
while bits[i] >= 2 {
let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break };
bits[i] -= 2;
bits[i - 1] += 1;
bits[j + 1] += 2;
bits[j] -= 1;
}
}
// drop the reserved symbol's code: it is one of the longest
if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) {
bits[i] -= 1;
}
// symbols by code length, then by value (K.4); the reserved symbol is not listed
let mut vals = Vec::new();
for len in 1..257 {
vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8));
}
let mut out = [0u8; 16];
for (o, &b) in out.iter_mut().zip(&bits[1..=16]) {
*o = b.min(255) as u8;
}
(out, vals)
}
fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] {
let q = quality.clamp(1, 100) as u32;
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
@@ -177,13 +267,15 @@ fn magnitude(v: i32) -> (u32, u32) {
(size, bits & ((1u32 << size) - 1))
}
fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huff) {
/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the
/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by
/// its `n` extra bits `bits`.
#[inline(always)]
fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) {
let d = q[0] as i32 - *pred;
*pred = q[0] as i32;
let (s, b) = magnitude(d);
let (c, l) = dc.code[s as usize];
w.put(c as u32, l as u32);
w.put(b, s);
sym(false, s as usize, b, s);
let mut run = 0u32;
for &zi in &ZIGZAG[1..] {
let v = q[zi] as i32;
@@ -192,22 +284,45 @@ fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huf
continue;
}
while run >= 16 {
let (c, l) = ac.code[0xF0];
w.put(c as u32, l as u32);
sym(true, 0xF0, 0, 0);
run -= 16;
}
let (s, b) = magnitude(v);
let (c, l) = ac.code[((run << 4) | s) as usize];
w.put(c as u32, l as u32);
w.put(b, s);
sym(true, ((run << 4) | s) as usize, b, s);
run = 0;
}
if run > 0 {
let (c, l) = ac.code[0x00];
w.put(c as u32, l as u32);
sym(true, 0x00, 0, 0);
}
}
/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma,
/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a
/// baseline coefficient needs at most 11).
type Token = u32;
/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]).
fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec<Token>, freq: &mut [Freq; 4]) {
let base = if chroma { 2 } else { 0 };
block_symbols(q, pred, |is_ac, s, bits, n| {
let table = base + is_ac as usize;
freq[table][s & 0xFF] += 1;
out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14);
});
}
/// Code a band's symbols with `tables` (indexed as in [`Token`]).
fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec<u8> {
let mut w = Bits::new();
for &t in tokens {
let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14);
let (c, l) = tables[table as usize].code[s as usize];
w.put(c as u32, l as u32);
w.put(bits, n);
}
w.finish()
}
/// Planar component samples (level-shifted to −128..127) with edge replication on read.
struct Plane {
w: usize,
@@ -249,7 +364,8 @@ fn dht(out: &mut Vec<u8>, class_id: u8, bits: &[u8; 16], vals: &[u8]) {
segment(out, 0xC4, &p);
}
/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped).
/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman
/// tables made for the image.
pub fn encode(
data: &[u8],
width: usize,
@@ -258,6 +374,21 @@ pub fn encode(
quality: u8,
sub: ChromaSubsampling,
app_segments: &[(u8, Vec<u8>)],
) -> Vec<u8> {
encode_with(data, width, height, channels, quality, sub, app_segments, true)
}
/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables.
#[allow(clippy::too_many_arguments)]
fn encode_with(
data: &[u8],
width: usize,
height: usize,
channels: usize,
quality: u8,
sub: ChromaSubsampling,
app_segments: &[(u8, Vec<u8>)],
optimize: bool,
) -> Vec<u8> {
let gray = channels == 1;
let (hs, vs) = match (gray, sub) {
@@ -331,42 +462,67 @@ pub fn encode(
let ql = scaled_quant(&Q_LUMA, quality);
let qc = scaled_quant(&Q_CHROMA, quality);
let (rl, rc) = (recip_table(&ql), recip_table(&qc));
let (dcl, acl) = (Huff::new(&DC_LUMA_BITS, &DC_VALS), Huff::new(&AC_LUMA_BITS, &AC_LUMA_VALS));
let (dcc, acc) = (Huff::new(&DC_CHROMA_BITS, &DC_VALS), Huff::new(&AC_CHROMA_BITS, &AC_CHROMA_VALS));
let (mcu_w, mcu_h) = (8 * hs, 8 * vs);
let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h));
// restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy
let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1));
let bands: Vec<usize> = (0..my.div_ceil(rows_per_band)).collect();
let encode_band = |band: &usize| -> Vec<u8> {
let mut w = Bits::new();
let mut pred = [0i32; 3];
// first pass: each band's quantized blocks as symbols, with their counts per table (DC
// prediction restarts per band, as the restart markers say)
let tokenize_band = |band: &usize| -> (Vec<Token>, [Freq; 4]) {
let (mut blk, mut q) = ([0f32; 64], [0i16; 64]);
let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]);
let mut pred = [0i32; 3];
for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) {
for mcu_x in 0..mx {
for vy in 0..vs {
for hx in 0..hs {
planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk);
fdct_quant(&blk, &rl, &mut q);
encode_block(&mut w, &q, &mut pred[0], &dcl, &acl);
tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq);
}
}
if !gray {
for k in 1..3 {
planes[k].block(mcu_x, mcu_y, &mut blk);
for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) {
plane.block(mcu_x, mcu_y, &mut blk);
fdct_quant(&blk, &rc, &mut q);
encode_block(&mut w, &q, &mut pred[k], &dcc, &acc);
tokenize_block(&q, pred, true, &mut tokens, &mut freq);
}
}
}
}
w.finish()
(tokens, freq)
};
#[cfg(feature = "parallel")]
let coded: Vec<Vec<u8>> = bands.par_iter().map(encode_band).collect();
let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect();
#[cfg(not(feature = "parallel"))]
let coded: Vec<Vec<u8>> = bands.iter().map(encode_band).collect();
let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.iter().map(tokenize_band).collect();
drop(planes);
// the tables: DC luma, AC luma, DC chroma, AC chroma
let tables: [([u8; 16], Vec<u8>); 4] = if optimize {
let mut total = [[0u64; 256]; 4];
for (_, f) in &tokenized {
for (t, f) in total.iter_mut().zip(f) {
for (t, f) in t.iter_mut().zip(f) {
*t = t.saturating_add(*f);
}
}
}
total.each_ref().map(optimal_table)
} else {
[
(DC_LUMA_BITS, DC_VALS.to_vec()),
(AC_LUMA_BITS, AC_LUMA_VALS.to_vec()),
(DC_CHROMA_BITS, DC_VALS.to_vec()),
(AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()),
]
};
let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals));
// second pass: code the symbols
#[cfg(feature = "parallel")]
let coded: Vec<Vec<u8>> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect();
#[cfg(not(feature = "parallel"))]
let coded: Vec<Vec<u8>> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect();
// headers
let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::<usize>() + 4096);
@@ -396,11 +552,12 @@ pub fn encode(
sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]);
}
segment(&mut out, 0xC0, &sof);
dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_VALS);
dht(&mut out, 0x10, &AC_LUMA_BITS, &AC_LUMA_VALS);
let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables;
dht(&mut out, 0x00, dlb, dlv);
dht(&mut out, 0x10, alb, alv);
if !gray {
dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_VALS);
dht(&mut out, 0x11, &AC_CHROMA_BITS, &AC_CHROMA_VALS);
dht(&mut out, 0x01, dcb, dcv);
dht(&mut out, 0x11, acb, acv);
}
let interval = (rows_per_band * mx) as u16;
if coded.len() > 1 {
@@ -495,6 +652,100 @@ mod tests {
}
}
/// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits,
/// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1).
fn check_table(bits: &[u8; 16], vals: &[u8]) {
assert_eq!(bits.iter().map(|&b| b as usize).sum::<usize>(), vals.len());
let mut seen = [false; 256];
for &v in vals {
assert!(!seen[v as usize], "symbol {v} listed twice");
seen[v as usize] = true;
}
let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum();
assert!(kraft < 1.0, "code space full: {kraft}");
let h = Huff::new(bits, vals);
for &v in vals {
let (c, l) = h.code[v as usize];
assert!((1..=16).contains(&l));
assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code");
}
}
#[test]
fn optimal_tables_are_valid_and_bounded() {
// one symbol, two, a typical spread and every symbol
let mut one = [0u64; 256];
one[7] = 5;
let mut two = [0u64; 256];
two[0] = 1;
two[0xF0] = 1_000_000;
let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 });
let all: Freq = std::array::from_fn(|s| s as u64 + 1);
// Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies
let mut fib = [0u64; 256];
let (mut a, mut b) = (1u64, 1u64);
for f in fib.iter_mut().take(40) {
*f = a;
(a, b) = (b, a + b);
}
for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] {
let (bits, vals) = optimal_table(&f);
check_table(&bits, &vals);
for s in 0..256 {
assert_eq!(vals.contains(&(s as u8)), f[s] > 0 || (name == "none" && s == 0), "{name}: symbol {s}");
}
}
// more frequent symbols never get longer codes
let (bits, vals) = optimal_table(&typical);
let h = Huff::new(&bits, &vals);
for &x in &vals {
for &y in &vals {
if typical[x as usize] > typical[y as usize] {
assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}");
}
}
}
// the Annex K example tables pass the same checks
check_table(&AC_LUMA_BITS, &AC_LUMA_VALS);
check_table(&DC_CHROMA_BITS, &DC_VALS);
}
/// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as
/// the Annex K example tables (the quantized coefficients are the same), at every quality and
/// subsampling, and both decoders read it.
#[test]
fn optimized_tables_shrink_files_without_changing_pixels() {
let (w, h) = (517, 333);
// gradients plus deterministic noise, like a photo's grain
let mut seed = 0x2545_f491_u32;
let src: Vec<u8> = test_rgb(w, h)
.into_iter()
.map(|v| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
v.saturating_add((seed % 23) as u8)
})
.collect();
for (q, sub) in [(100, ChromaSubsampling::S444), (92, ChromaSubsampling::S444), (75, ChromaSubsampling::S420), (30, ChromaSubsampling::S420)]
{
let std = encode_with(&src, w, h, 3, q, sub, &[], false);
let opt = encode(&src, w, h, 3, q, sub, &[]);
assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len());
let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables");
let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables");
assert_eq!(a, b, "q{q} {sub:?}: pixels differ");
let z = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg");
assert_eq!(z.len(), b.len());
}
// grayscale too
let gray: Vec<u8> = src.iter().step_by(3).copied().collect();
let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false);
let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]);
assert!(opt.len() < std.len());
assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap());
}
#[test]
fn grayscale_and_quality_monotone() {
let (w, h) = (300, 200);
+21 -6
View File
@@ -12,6 +12,7 @@
//! - [`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.
//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM.
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
@@ -31,16 +32,18 @@ mod jxl;
mod jxl_encode;
mod other;
mod png_codec;
pub mod print;
pub mod proof;
mod psd;
mod sniff;
pub mod space;
mod tiff_codec;
pub mod video;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression,
encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal,
};
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
pub use sniff::{Format, sniff};
@@ -244,12 +247,24 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
}
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
///
/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest
/// of the pipeline (issue #1212).
pub fn to_working(d: &Decoded) -> Rgb32f {
if d.space.is_working() {
return d.image.clone();
let mut img = if d.space.is_working() {
d.image.clone()
} else {
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
};
// white is (1, 1, 1) in the working space too: the conversion is white-preserving
if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) {
for (p, &av) in img.data.iter_mut().zip(&a.data) {
let av = av.clamp(0.0, 1.0);
*p = p.map(|c| c * av + (1.0 - av));
}
}
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
img
}
/// Where a thumbnail came from.
+375
View File
@@ -0,0 +1,375 @@
//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the
//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space
//! of the chosen printer / paper profile.
//!
//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the
//! page to the device's own values. When the printer manages colour instead, the page is sent in a
//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it.
//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]).
//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is
//! measured by a round trip of black through the profile, and the page's linear values are scaled
//! so its black lands there instead of every shadow below it clipping to the same ink. Linear
//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping
//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black
//! mapping and are left alone.
//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or
//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown
//! on screen (the print is what it corrects).
//!
//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table
//! indexed by the 16-bit sample; the conversion itself is moxcms's.
//!
//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point
//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise.
use std::sync::Arc;
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions};
use crate::display::MAX_PROFILE_BYTES;
use crate::proof::ProofIntent;
use crate::space::{NamedSpace, Trc};
/// Where a print's colours go.
#[derive(Clone, Debug, PartialEq)]
pub enum PrintTarget {
/// The printer manages colour: the page is sent in this RGB space, with its profile embedded.
Managed(NamedSpace),
/// A printer / paper ICC profile: the page is converted to the device's values.
Profile(Arc<Vec<u8>>),
}
/// How the page is rendered into the target.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PrintColor {
pub intent: ProofIntent,
/// Map the page's black to the device's darkest (relative colorimetric only).
pub black_point_compensation: bool,
/// −100..100; 0 leaves the page as rendered.
pub brightness: f32,
/// −100..100; 0 leaves the page as rendered.
pub contrast: f32,
}
impl Default for PrintColor {
fn default() -> Self {
PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 }
}
}
/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size.
const BATCH: usize = 1 << 16;
/// The darkest black point compensation will lift the page's black to. A profile whose round trip
/// says more is broken or not a print profile; leave the page alone rather than wash it out.
const MAX_BLACK: f32 = 0.5;
type Executor = Arc<dyn TransformExecutor<f32> + Send + Sync>;
/// A page conversion, built once per print job and applied to every page.
pub struct PrintTransform {
forward: Executor,
layout: Layout,
channels: usize,
icc: Arc<Vec<u8>>,
/// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation.
pre: Vec<f32>,
black: f32,
description: Option<String>,
}
impl std::fmt::Debug for PrintTransform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PrintTransform")
.field("channels", &self.channels)
.field("black", &self.black)
.field("description", &self.description)
.finish_non_exhaustive()
}
}
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
}
fn rendering_intent(intent: ProofIntent) -> RenderingIntent {
match intent {
ProofIntent::Perceptual => RenderingIntent::Perceptual,
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
}
}
/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0).
fn amount(v: f32) -> f32 {
if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 }
}
/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a
/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black,
/// mid grey and white where they are and stays monotonic for |k| < 1.
pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 {
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
let (b, c) = (amount(brightness), amount(contrast));
let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e };
let k = 0.6 * c;
let tau = std::f32::consts::TAU;
(e - k * (tau * e).sin() / tau).clamp(0.0, 1.0)
}
impl PrintTransform {
/// Build the conversion for a target. Errors say what's wrong with the profile; nothing here
/// panics on a hostile one.
pub fn new(target: &PrintTarget, color: &PrintColor) -> Result<PrintTransform, String> {
let icc = match target {
PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)),
PrintTarget::Profile(bytes) => Arc::clone(bytes),
};
if icc.len() > MAX_PROFILE_BYTES {
return Err("the profile is too large".into());
}
let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?;
let (layout, channels) = 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 printer profiles can be printed to".into()),
};
let lin = crate::icc::linear_rec2020_profile();
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok())
.ok_or("can't convert to this profile")?;
let black = if color.black_point_compensation && color.intent == ProofIntent::Relative {
let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok())
.ok_or("can't convert from this profile")?;
device_black(&forward, &back, channels)
} else {
0.0
};
let decode = Trc::Rec709.lut16();
let (b, c) = (color.brightness, color.contrast);
let adjust = amount(b) != 0.0 || amount(c) != 0.0;
let pre = decode
.iter()
.map(|&lin| {
let lin = if adjust {
let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0));
lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c))
} else {
lin.clamp(0.0, 1.0)
};
lin * (1.0 - black) + black
})
.collect();
let description = crate::icc::parse(&icc).and_then(|i| i.description);
Ok(PrintTransform { forward, layout, channels, icc, pre, black, description })
}
/// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey).
pub fn channels(&self) -> usize {
self.channels
}
/// The profile the converted samples are in: embed it with them.
pub fn icc(&self) -> &Arc<Vec<u8>> {
&self.icc
}
/// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise,
/// so writers need to know).
pub fn is_cmyk(&self) -> bool {
self.layout == Layout::Rgba
}
/// The linear black black point compensation lifts the page's black to (0 when off).
pub fn black(&self) -> f32 {
self.black
}
/// The profile's description, for the job's settings and error messages.
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
/// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result
/// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel).
pub fn apply(&self, rgb: &[u16]) -> Result<Vec<u16>, String> {
if !rgb.len().is_multiple_of(3) {
return Err("the page isn't whole RGB pixels".into());
}
let pixels = rgb.len() / 3;
let ch = self.channels;
let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?;
let mut out = vec![0u16; out_len];
let run = |src: &[u16], dst: &mut [u16]| -> Option<()> {
let lin: Vec<f32> = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect();
let mut dev = vec![0f32; dst.len()];
self.forward.transform(&lin, &mut dev).ok()?;
for (d, v) in dst.iter_mut().zip(dev) {
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
*d = (v * 65535.0 + 0.5) as u16;
}
Some(())
};
let src = rgb.chunks(BATCH * 3);
let dst = out.chunks_mut(BATCH * ch);
#[cfg(feature = "parallel")]
let ok = {
use rayon::prelude::*;
let pairs: Vec<_> = src.zip(dst).collect();
pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some())
};
#[cfg(not(feature = "parallel"))]
let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some());
if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) }
}
}
/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative
/// colorimetric both ways (for a device that reaches true black, 0).
fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 {
let mut device = vec![0f32; channels];
let mut lin = [0f32; 3];
let ok = guarded(|| {
forward.transform(&[0.0; 3], &mut device).ok()?;
back.transform(&device, &mut lin).ok()
});
if ok.is_none() {
return 0.0;
}
// BT.2020 luminance weights
let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2];
if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
use moxcms::ToneReprCurve;
/// A page of one 16-bit pixel per linear grey level.
fn greys(levels: &[f32]) -> Vec<u16> {
levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect()
}
fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec<u16> {
PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap()
}
/// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey.
fn raised_black_profile() -> Arc<Vec<u8>> {
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap();
let (g, e) = (2.2f32, 0.03f32);
let a = (1.0 - e).powf(1.0 / g);
// ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below
let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]);
p.red_trc = Some(trc.clone());
p.green_trc = Some(trc.clone());
p.blue_trc = Some(trc);
Arc::new(p.encode().unwrap())
}
#[test]
fn managed_srgb_keeps_black_grey_and_white() {
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap();
assert_eq!(t.channels(), 3);
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb));
let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap();
let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32;
assert!(out[..3].iter().all(|&v| v < 64), "{out:?}");
assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}");
assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}");
}
#[test]
fn adobe_rgb_target_embeds_its_profile() {
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap();
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb));
// a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped
let px = [0u16, 65535, 0];
let out = t.apply(&px).unwrap();
assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}");
}
#[test]
fn brightness_lifts_the_mid_tones_and_holds_the_ends() {
let target = PrintTarget::Managed(NamedSpace::Srgb);
let levels = [0.0, 0.18, 1.0];
let plain = convert(&target, PrintColor::default(), &levels);
let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels);
let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels);
assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}");
for v in [&plain, &bright, &dark] {
assert!(v[0] < 64 && v[6] > 65400, "{v:?}");
}
}
#[test]
fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() {
let target = PrintTarget::Managed(NamedSpace::Srgb);
let levels = [0.03, 0.6];
let plain = convert(&target, PrintColor::default(), &levels);
let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels);
assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}");
for c in [-100.0, 100.0] {
for b in [-100.0, 100.0] {
let ramp: Vec<f32> = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect();
assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}");
assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6);
}
}
}
#[test]
fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() {
let target = PrintTarget::Profile(raised_black_profile());
let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0];
let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() };
let clipped = convert(&target, relative, &shadows);
let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows);
let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::<Vec<_>>();
let (clipped, kept) = (reds(&clipped), reds(&kept));
// everything below the ink's black prints as (within rounding) the same device value without
// compensation …
assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}");
// … and as distinct, rising values with it; white still prints as white
assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}");
assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}");
let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap();
assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black());
}
#[test]
fn black_point_compensation_is_relative_colorimetric_only() {
let target = PrintTarget::Profile(raised_black_profile());
let levels = [0.0, 0.01, 0.2];
let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels);
let without =
convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels);
assert_eq!(with, without);
let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap();
assert_eq!(t.black(), 0.0);
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let color = PrintColor::default();
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err());
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err());
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err());
let good = crate::icc::write_named(NamedSpace::DisplayP3);
for i in (0..good.len()).step_by(7) {
let mut b = good.clone();
b[i] ^= 0x5a;
if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) {
let _ = t.apply(&greys(&[0.0, 0.5, 1.0]));
}
}
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color })
.unwrap();
assert!(t.apply(&[1, 2]).is_err(), "half a pixel");
assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18]));
assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite());
}
}
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec<PathBuf> {
fn corpus_images_decode() {
let dir = corpus_root().join("images");
if !dir.is_dir() {
eprintln!("skip: {} absent", dir.display());
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
return;
}
let (mut ok, mut unsupported) = (0, 0);
@@ -57,7 +57,7 @@ fn corpus_images_decode() {
fn corpus_pngsuite() {
let dir = corpus_root().join("pngsuite");
if !dir.is_dir() {
eprintln!("skip: {} absent", dir.display());
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
return;
}
let mut n = 0;
@@ -83,7 +83,7 @@ fn corpus_pngsuite() {
fn corpus_raw_is_routed() {
let dir = corpus_root().join("raw");
if !dir.is_dir() {
eprintln!("skip: {} absent", dir.display());
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
return;
}
for p in files(&dir) {
@@ -103,7 +103,7 @@ fn system_icc_profiles() {
use lightcraft_codecs::icc::{IccColorModel, IccKind, parse};
let dir = Path::new("/System/Library/ColorSync/Profiles");
if !dir.is_dir() {
eprintln!("skip: no system ICC profiles");
lightcraft_testkit::skip!(System, "no system ICC profiles");
return;
}
let expect = [
+18
View File
@@ -140,6 +140,24 @@ fn png_metadata_and_gray_alpha() {
assert!((a - 128.0 / 255.0).abs() < 1e-6);
}
/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212:
/// transparent areas, stored black, rendered black).
#[test]
fn png_transparency_develops_on_white() {
// opaque red, transparent black (as most encoders store it), half-transparent red
let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128];
let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap();
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert!(d.has_alpha);
let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap();
let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0);
let w = d.to_working();
let a = 128.0 / 255.0;
for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] {
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
}
}
#[test]
fn p3_icc_to_working() {
let icc = write_named(NamedSpace::DisplayP3);
+5
View File
@@ -5,3 +5,8 @@ No weights or inference backend. See [the user workflow](../../docs/denoise.md).
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
crate directly when inference is disabled. The default download catalog is empty.
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.
The Bayer runner rejects non-finite source samples before inference and non-finite
results after gain matching, highlight protection and tile blending. Finite negative
and HDR source samples remain supported. These guards do not implement Raw Details
or Super Resolution.
+56
View File
@@ -95,6 +95,9 @@ pub fn denoise_bayer(
if mosaic.len() != pixels {
return Err(Error::Input("the mosaic does not have width × height samples".into()));
}
if mosaic.iter().any(|v| !v.is_finite()) {
return Err(Error::Input("the mosaic has samples that are not finite".into()));
}
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
return Err(Error::Input("the tile size and overlap do not fit".into()));
}
@@ -148,6 +151,9 @@ pub fn denoise_bayer(
return Err(Error::Model("numbers that are not finite".into()));
}
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
if out.iter().any(|v| !v.is_finite()) {
return Err(Error::Model("numbers that are not finite after scale matching or highlight protection".into()));
}
Ok(out)
})
.collect()
@@ -201,6 +207,9 @@ pub fn denoise_bayer(
}
}
});
if acc.iter().flatten().any(|v| !v.is_finite()) {
return Err(Error::Model("numbers that are not finite after tile blending".into()));
}
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!(
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
@@ -388,6 +397,53 @@ mod tests {
Params { tile, overlap, gain, clip: None, parallel: 2 }
}
#[test]
fn nonfinite_mosaics_are_rejected_before_inference() {
struct Counting(std::sync::atomic::AtomicUsize);
impl TileRunner for Counting {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(vec![0.0; 3 * 32 * 32])
}
}
let model = Counting(std::sync::atomic::AtomicUsize::new(0));
let layout = Layout { dx: 0, dy: 0 };
for sample in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(denoise_bayer(&[sample], 1, 1, layout, &model, &params(16, 0, Gain::None), &Control::default()), Err(Error::Input(_))));
}
assert_eq!(model.0.load(Ordering::Relaxed), 0);
// Finite scene-linear samples can be negative or exceed SDR white.
for sample in [-0.25, 2.0] {
assert!(denoise_bayer(&[sample], 1, 1, layout, &model, &params(16, 0, Gain::None), &Control::default()).is_ok());
}
}
#[test]
fn finite_model_output_that_overflows_during_gain_matching_is_rejected() {
struct Extreme;
impl TileRunner for Extreme {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
Ok(vec![f32::MAX; 3 * 32 * 32])
}
}
let p = params(16, 0, Gain::MatchMean { nominal: 1e-6, max_deviation: 0.0 });
assert!(matches!(denoise_bayer(&[0.5], 1, 1, Layout { dx: 0, dy: 0 }, &Extreme, &p, &Control::default()), Err(Error::Model(_))));
}
#[test]
fn overlapping_finite_tiles_that_overflow_the_accumulator_are_rejected() {
struct Extreme;
impl TileRunner for Extreme {
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
Ok(vec![f32::MAX; 3 * 32 * 32])
}
}
assert!(matches!(
denoise_bayer(&vec![0.5; 48 * 48], 48, 48, Layout { dx: 0, dy: 0 }, &Extreme, &params(16, 4, Gain::None), &Control::default()),
Err(Error::Model(_))
));
}
#[test]
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
+25 -1
View File
@@ -97,7 +97,12 @@ fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
return invalid("malformed LeakyRelu");
}
let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
// only an omitted alpha gets the ONNX default; one that is not a single float must not read as 0
let alpha = match next.attribute.as_slice() {
[] => 0.01,
[AttributeProto { f: Some(f), kind: 0 | 1, .. }] => *f,
_ => return invalid("LeakyRelu alpha is not a single float"),
};
i += 1;
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
return Ok((Some(alpha), name));
@@ -319,6 +324,25 @@ mod tests {
let _ = std::fs::remove_file(p);
}
#[test]
fn a_leaky_relu_alpha_that_is_not_a_float_is_rejected() {
let good = unet_onnx(4, 3, 1, 7);
assert!(read_bytes(&good).is_ok());
// an alpha attribute: float field 2, then the declared type (field 20) FLOAT
let mut alpha = vec![0x15];
alpha.extend_from_slice(&0.2f32.to_le_bytes());
let at = good.windows(alpha.len()).position(|w| w == alpha.as_slice()).unwrap() + alpha.len();
assert_eq!(good[at..at + 3], [0xa0, 0x01, 1]);
// declared INT: the float must not be taken as the slope
let mut wrong_type = good.clone();
wrong_type[at + 2] = 2;
assert!(matches!(read_bytes(&wrong_type), Err(NetError::Invalid(_))));
// declared FLOAT but no float value (the float field becomes an int field 3 = 0 of the same length): must not read as 0
let mut no_float = good.clone();
no_float[at - 5..at].copy_from_slice(&[0x18, 0x80, 0x80, 0x80, 0x00]);
assert!(matches!(read_bytes(&no_float), Err(NetError::Invalid(_))));
}
#[test]
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
+6 -3
View File
@@ -113,7 +113,10 @@ fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
#[derive(Default)]
pub(super) struct AttributeProto {
pub name: String,
pub f: f32,
/// `None` when the file has no float value for the attribute.
pub f: Option<f32>,
/// The declared attribute type (1 float, 2 int, 3 string, 7 ints); 0 when the file does not say.
pub kind: i64,
pub i: i64,
pub s: Vec<u8>,
pub ints: Vec<i64>,
@@ -151,7 +154,7 @@ fn attribute(b: &[u8]) -> Result<AttributeProto> {
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => a.name = string(b)?,
(2, Wire::F32(v)) => a.f = v,
(2, Wire::F32(v)) if a.f.is_none() => a.f = Some(v),
(3, Wire::Int(v)) => a.i = v as i64,
(4, Wire::Bytes(b)) => {
if b.len() > MAX_STRING {
@@ -160,7 +163,7 @@ fn attribute(b: &[u8]) -> Result<AttributeProto> {
a.s = b.to_vec();
}
(8, w) => ints(w, &mut a.ints, 16)?,
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
(20, Wire::Int(v @ (1 | 2 | 3 | 7))) if a.kind == 0 => a.kind = v as i64,
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
}
}
+79
View File
@@ -99,8 +99,45 @@ impl ControlSpec {
let s = format!("{:.*}", self.decimals as usize, v);
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
}
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
/// linear.
pub fn position(&self, v: f64) -> f64 {
let v = self.clamp(v);
let p = match self.kelvin_scale() {
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
None => (v - self.min) / (self.max - self.min).max(1e-9),
};
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
}
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
pub fn value_at(&self, p: f64) -> f64 {
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
let v = match self.kelvin_scale() {
Some(_) if p >= 1.0 => self.max,
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
None => self.min + p * (self.max - self.min),
};
self.clamp(v)
}
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
/// `None` for every other control, or a range the centre isn't inside.
fn kelvin_scale(&self) -> Option<(f64, f64)> {
let centre = KELVIN_CENTRE;
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
}
}
/// The Kelvin temperature at the middle of its track, as in Lightroom.
const KELVIN_CENTRE: f64 = 7000.0;
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
const KELVIN_KNEE: f64 = 1000.0;
macro_rules! controls {
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
/// All numeric develop controls, in panel order.
@@ -508,4 +545,46 @@ mod tests {
assert_eq!(e.format(-1.0), "-1.00");
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
}
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
#[test]
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
let temp = find("wb.temp").unwrap();
for (k, lightroom, model) in [
(2000.0, 0.0, 0.0),
(3650.0, 0.167, 0.165),
(5400.0, 0.333, 0.34),
(7000.0, 0.5, 0.5),
(15000.0, 0.79, 0.79),
(20000.0, 0.853, 0.849),
(30000.0, 0.90, 0.92),
(50000.0, 1.0, 1.0),
] {
let p = temp.position(k);
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
}
// every 50 K step survives value → position → value, and the track never runs backwards
let mut last = -1.0;
for k in (2000..=50000).step_by(50).map(f64::from) {
let p = temp.position(k);
assert!(p > last, "{k} K");
last = p;
let back = (temp.value_at(p) / temp.step).round() * temp.step;
assert_eq!(back, k, "{k} K → {p} → {back}");
}
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
let exposure = find("light.exposure").unwrap();
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
}
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
}
}
+2 -2
View File
@@ -10,6 +10,7 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::borrow::Cow;
use crate::DevelopSettings;
@@ -185,8 +186,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
/// through the control specs.
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) };
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), 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 {
+14 -1
View File
@@ -900,11 +900,24 @@ pub struct BrushStroke {
pub density: f64,
pub erase: bool,
pub auto_mask: bool,
/// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's
/// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the
/// end of a shorter list takes the last value.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pressure: Vec<f32>,
}
impl BrushStroke {
/// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1).
pub fn pressure_at(&self, i: usize) -> f32 {
let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0);
if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) }
}
}
impl Default for BrushStroke {
fn default() -> Self {
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false }
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() }
}
}
+3
View File
@@ -29,6 +29,7 @@ lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
lightcraft-geocode = { workspace = true }
lightcraft-slideshow = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -51,6 +52,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
# 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).
+7 -2
View File
@@ -3,7 +3,7 @@
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
//! script only reads a local directory (no network).
//!
//! wasm32 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
//! watermark falls back to it there. Native builds embed every font.
use std::fmt::Write as _;
@@ -69,6 +69,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
let mut out = String::new();
let mut has_japanese_ui = false;
let mut has_chinese_ui = false;
let mut has_thai_ui = false;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
@@ -76,7 +77,8 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
};
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai");
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
@@ -89,6 +91,9 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
path.display().to_string(),
);
}
if required && !has_thai_ui {
return Err("required Thai UI face Noto Sans Thai Regular (Thai) is missing from fonts/manifest.txt".into());
}
if required && !has_japanese_ui {
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
}
+42
View File
@@ -0,0 +1,42 @@
//! UI-thread work one slider-drag tick costs outside the render: `develop.set` inside an
//! interaction, the filmstrip's `visible()` after the catalog revision moved, and the settings
//! hash the loupe takes every frame. `cargo run --release -p lightcraft-engine --example
//! drag_tick_bench -- [photos]` (default 20000).
#![allow(clippy::unwrap_used, clippy::expect_used)]
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use lightcraft_engine::Session;
use serde_json::json;
use std::time::Instant;
fn main() {
let n: u64 = std::env::args().nth(1).and_then(|a| a.parse().ok()).unwrap_or(20_000);
let mut s = Session::new();
for i in 1..=n {
let mut p = Photo::new(PhotoId(i), Source::File { path: format!("/x/{i}.ARW") }, &format!("{i}.ARW"), "ARW", 9984, 6656, "2026-01-01");
p.captured = Some(format!("2025-{:02}-{:02}T10:{:02}:00", 1 + i % 12, 1 + i % 28, i % 60));
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
s.execute("library.select", &json!({"ids": [1]})).unwrap();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
s.visible_cloned();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
let ticks = 30;
let (mut set, mut vis, mut hash) = (0.0, 0.0, 0.0);
for k in 0..ticks {
let t = Instant::now();
s.execute("develop.set", &json!({"control": "light.exposure", "value": k as f64 / 30.0})).unwrap();
set += ms(t);
let t = Instant::now();
let v = s.visible_cloned();
vis += ms(t);
assert_eq!(v.len() as u64, n);
let d = s.develop_of(PhotoId(1)).unwrap();
let t = Instant::now();
std::hint::black_box(d.hash64());
hash += ms(t);
}
s.execute("develop.endInteraction", &json!({})).unwrap();
let f = ticks as f64;
println!("{n} photos, per tick: develop.set {:.3} ms, visible() {:.3} ms, settings hash64 {:.3} ms", set / f, vis / f, hash / f);
}
+499
View File
@@ -0,0 +1,499 @@
//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no
//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame --
//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries
//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record
//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 /
//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most
//! Fujifilm, Panasonic and Olympus files) get the edge bands only.
//!
//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size
//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is
//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak
//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths
//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the
//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several
//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG
//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off);
//! a smaller preview is not aligned.
//!
//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark*
//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples
//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the
//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A
//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a
//! survey aid: a dark band can also be deep shadow, so read it with the frame.
use std::collections::BTreeMap;
use lightcraft_codecs::DecodeOptions;
use lightcraft_raw::RawData;
/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3
/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower).
const MIN_SCORE: f64 = 0.5;
const MIN_MARGIN: f64 = 0.02;
/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment.
const MAX_FRACTION: f64 = 0.35;
/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep
/// up to ~280 px of masked border).
const MAX_SEARCH: usize = 192;
/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine.
const FULL_SEARCH: usize = 2048;
/// Edge bands are counted up to this many lines from each edge.
const MAX_BAND: usize = 128;
/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area),
/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own
/// files agree on the same frame.
const MIN_FILES: usize = 3;
struct Frame {
jpeg: (usize, usize),
/// Sub-pixel offset of the JPEG's top left corner in sensor pixels.
at: (f64, f64),
score: f64,
margin: f64,
}
impl Frame {
fn whole(&self) -> (usize, usize) {
(self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize)
}
fn clean(&self) -> bool {
let frac = |v: f64| (v - v.round()).abs();
self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION
}
}
/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge.
#[derive(Clone, Copy, Default)]
struct Band {
dark: usize,
masked: usize,
}
impl Band {
fn total(self) -> usize {
self.dark + self.masked
}
}
struct Measured {
model: String,
size: (usize, usize),
bits: u32,
black: f64,
white: f64,
/// The crop the decoder applies, in sensor-data coordinates: x, y, width, height.
crop: (usize, usize, usize, usize),
/// Left, right, top, bottom.
bands: [Band; 4],
frame: Result<Frame, String>,
/// With a clean frame: what the crop holds beyond it.
beyond: Option<[Beyond; 4]>,
}
impl Measured {
/// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture.
fn in_crop(&self) -> [usize; 4] {
let (w, h) = self.size;
let (x, y, cw, ch) = self.crop;
let [l, r, t, b] = self.bands.map(Band::total);
[l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))]
}
}
/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are
/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the
/// camera, harmless).
#[derive(Clone, Copy, Default)]
struct Beyond {
masked: usize,
dark: usize,
picture: usize,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let json = args.iter().any(|a| a == "--json");
let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect();
if files.is_empty() {
eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)");
std::process::exit(2);
}
// model, data size, JPEG size -> (bit depth, frame) of each file
let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new();
for f in files {
let m = match measure(f) {
Ok(m) => m,
Err(e) => {
if json {
println!("{}", serde_json::json!({ "file": f, "error": e }));
} else {
println!("{f}: skipped: {e}");
}
continue;
}
};
let in_crop = m.in_crop();
if json {
let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked });
let frame = match &m.frame {
Ok(fr) => serde_json::json!({
"jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean()
}),
Err(e) => serde_json::json!({ "error": e }),
};
println!(
"{}",
serde_json::json!({
"file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white,
"crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3],
"bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) },
"in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] },
"beyond_frame": m.beyond.map(|b| {
let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture });
serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) })
}),
"frame": frame,
})
);
} else {
let frame = match &m.frame {
Ok(fr) => format!(
"JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}",
fr.jpeg.0,
fr.jpeg.1,
fr.at.0,
fr.at.1,
fr.score,
fr.margin,
if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" }
),
Err(e) => format!("no frame: {e}"),
};
println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits);
let side = |name: &str, b: Band| match (b.dark, b.masked) {
(0, 0) => None,
(d, 0) => Some(format!("{name} {d} dark")),
(0, k) => Some(format!("{name} {k} masked")),
(d, k) => Some(format!("{name} {d} dark + {k} masked")),
};
let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect();
let shown: Vec<String> =
["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect();
println!(
" edge bands: {}; decoder crop ({}, {}, {}, {}){}",
if bands.is_empty() { "none".to_string() } else { bands.join(", ") },
m.crop.0,
m.crop.1,
m.crop.2,
m.crop.3,
match m.beyond {
Some(b) => {
let parts: Vec<String> = ["left", "right", "top", "bottom"]
.iter()
.zip(b)
.filter(|(_, b)| b.masked + b.dark + b.picture > 0)
.map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture))
.collect();
if parts.is_empty() {
"; nothing beyond the camera frame".to_string()
} else {
format!("; beyond the camera frame: {}", parts.join(", "))
}
}
None if shown.is_empty() => String::new(),
None => format!(" still includes edge bands: {} lines", shown.join(", ")),
}
);
}
if let Ok(fr) = m.frame {
groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr));
}
}
if json {
return;
}
println!();
// each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree
let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups
.iter()
.filter_map(|(key, frames)| {
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
let first = *offsets.first()?;
offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len())))
})
.collect();
for (key, frames) in &groups {
let (model, size, jpeg) = key;
let clean = frames.iter().filter(|(_, f)| f.clean()).count();
let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect();
depths.sort_unstable();
depths.dedup();
let label = format!("{model} {}×{}", size.0, size.1);
let Some(&((x, y), _)) = agreed.get(key) else {
if clean == 0 {
println!("{label}: no clean alignment in {} files; no entry", frames.len());
} else {
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
println!("{label}: files disagree: {offsets:?}; no entry");
}
continue;
};
let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() };
println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len());
// siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on
// the same frame
let siblings: Vec<(&str, usize)> = agreed
.iter()
.filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y))
.map(|((m, _, _), (_, n))| (m.as_str(), *n))
.collect();
let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>();
if clean < MIN_FILES {
if pooled < MIN_FILES {
println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled);
continue;
}
let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect();
println!(" with siblings of the same readout and frame: {}", names.join(", "));
}
for bits in depths {
println!(
" MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},",
size.0, size.1, jpeg.0, jpeg.1
);
}
}
}
fn measure(path: &str) -> Result<Measured, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?;
let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) };
if raw.cpp != 1 || raw.cfa.is_none() {
return Err("not colour-filter sensor data".into());
}
let (w, h) = (raw.width, raw.height);
if w < 4 || h < 4 || d.len() < w.saturating_mul(h) {
return Err("sensor data shorter than its size".into());
}
let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0);
let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0);
let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0));
let bands = [
band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white),
band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white),
band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white),
band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white),
];
// the decoder's crop is relative to the active area: in sensor-data coordinates here
let a = raw.active_area;
let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height);
let frame = frame(&bytes, d, w, h);
let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| {
let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg);
let kinds = |lines: Vec<Line>| {
lines.iter().fold(Beyond::default(), |mut b, l| {
match kind(l, black, white) {
Kind::Dark => b.dark += 1,
Kind::Masked => b.masked += 1,
Kind::Picture => b.picture += 1,
}
b
})
};
let col = |x: usize| line(h, |i| at(x, i));
let row = |y: usize| line(w, |i| at(i, y));
[
kinds((c.x..fx.min(c.x + c.width)).map(col).collect()),
kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()),
kinds((c.y..fy.min(c.y + c.height)).map(row).collect()),
kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()),
]
});
Ok(Measured {
model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(),
size: (w, h),
bits: raw.bits,
black,
white,
crop: (c.x, c.y, c.width, c.height),
bands,
frame,
beyond,
})
}
/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart)
/// and the number of sample pairs.
struct Line {
mean: f64,
pattern: f64,
spread: f64,
noise: f64,
pairs: f64,
}
fn line(n: usize, at: impl Fn(usize) -> f64) -> Line {
let stride = ((n / 512).max(1)).div_ceil(2) * 2;
let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64);
let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect();
for &i in &idx {
even += at(i);
odd += at(i + 1);
k += 1.0;
}
let (even, odd) = (even / k.max(1.0), odd / k.max(1.0));
for &i in &idx {
var += (at(i) - even).powi(2);
noise += (at(i + 2) - at(i)).powi(2) / 2.0;
}
Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k }
}
enum Kind {
Dark,
Masked,
Picture,
}
fn kind(l: &Line, black: f64, white: f64) -> Kind {
let range = white - black;
let slack = white / 4096.0;
if l.mean < black + range * 0.01 {
Kind::Dark
} else if l.mean > black + range * 0.02
&& l.mean < white * 0.25
// patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler)
&& (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004)
&& l.spread < 1.5 * l.noise + slack
&& l.spread < white * 0.004
{
Kind::Masked
} else {
Kind::Picture
}
}
fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band {
let mut b = Band::default();
for l in lines {
match kind(&l, black, white) {
Kind::Dark => b.dark += 1,
Kind::Masked => b.masked += 1,
Kind::Picture => break,
}
}
b
}
fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> {
let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?;
let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?;
let (jw, jh) = (jpeg.image.width, jpeg.image.height);
if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 {
return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data"));
}
// half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike
let (rw, rh) = (w / 2, h / 2);
let raw_y: Vec<f64> = (0..rh * rw)
.map(|i| {
let (x, y) = (2 * (i % rw), 2 * (i / rw));
let s: f64 =
[(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum();
s.sqrt()
})
.collect();
let (pw, ph) = (jw / 2, jh / 2);
let px = &jpeg.image.data;
let jpg_y: Vec<f64> = (0..ph * pw)
.map(|i| {
let (x, y) = (2 * (i % pw), 2 * (i / pw));
let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]
.iter()
.map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0)))
.sum();
s.sqrt()
})
.collect();
let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH));
let grad = |img: &[f64], stride: usize, x: usize, y: usize| {
let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0);
(at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y))
};
let normalise = |v: Vec<f64>| {
let n = v.len().max(1) as f64;
let mean = v.iter().sum::<f64>() / n;
let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12);
v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>()
};
// gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset
let sample = |step: usize| {
let points: Vec<(usize, usize)> =
(0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect();
let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect());
(points, gj)
};
let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| {
let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect());
gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64
};
let fine = sample(2);
// every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset
// around the best of those
let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1];
let mut coarse_best = f64::NEG_INFINITY;
let (mut cx, mut cy) = (0, 0);
let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH {
(0..=sx, 0..=sy)
} else {
let coarse = sample(8);
for dy in (0..=sy).step_by(4) {
for dx in (0..=sx).step_by(4) {
let v = score(&coarse, dx, dy);
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
*s = v;
}
if v > coarse_best {
(coarse_best, cx, cy) = (v, dx, dy);
}
}
}
(cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy))
};
for dy in window.1.clone() {
for dx in window.0.clone() {
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
*s = score(&fine, dx, dy);
}
}
}
let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY);
let (mut bx, mut by) = (0, 0);
for dy in 0..=sy {
for dx in 0..=sx {
if at(dx, dy) > at(bx, by) {
(bx, by) = (dx, dy);
}
}
}
let best = at(bx, by);
let runner_up = (0..=sy)
.flat_map(|dy| (0..=sx).map(move |dx| (dx, dy)))
.filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1)
.map(|(dx, dy)| at(dx, dy))
.fold(f64::NEG_INFINITY, f64::max);
// parabola through the peak and its neighbours (none at the window's edge)
let refine = |a: f64, b: f64, c: f64| {
let k = a - 2.0 * b + c;
if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 }
};
let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 };
let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 };
Ok(Frame {
jpeg: (jw, jh),
at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)),
score: best,
margin: if runner_up.is_finite() { best - runner_up } else { best },
})
}
+136 -3
View File
@@ -9,7 +9,15 @@
//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU
//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores.
//! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `lightcraft-gpu`
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only).
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only, `GPU_ONLY=1` for the GPU only).
//!
//! `ONLY=window`: a 1:1 zoom window cut from the full-size original (the loupe's `Slot::Region`):
//! the first window of a photo, a pan, and exposure / draft / NR changes with a warm stage cache;
//! `DUMP=file` writes the GPU window's RGBA bytes, for pixel A/B comparisons between builds.
//! Opening a photo, per file: `ONLY=source` (decode steps), `ONLY=load` (what the app's loaders
//! cost: file read, decode, binning, embedded stand-ins, `load_bytes(2560)`, the fs loader),
//! `ONLY=load1` (decode checksum + `load_bytes(2560)`; `LOADONLY=1`: only that, for a profiler),
//! `ONLY=jpeg` (a JPEG decoded to fit 256–2560 px boxes).
use std::sync::Arc;
use std::time::Instant;
@@ -88,6 +96,63 @@ fn main() {
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
let only = std::env::var("ONLY").unwrap_or_default();
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
if only == "jpeg" {
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
for path in &args {
let bytes = std::fs::read(path).expect("read");
for edge in [256u32, 512, 1600, 2560] {
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
println!(
" {path} fit {edge}: {}×{} {}",
d.image.width,
d.image.height,
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
);
}
}
return;
}
if only == "load" || only == "load1" {
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
for path in &args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
if std::env::var("LOADONLY").is_ok() {
// only the preview load (for a profiler)
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
if let lightcraft_raw::RawData::U16(d) = &raw.data {
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
println!(" decode checksum: {sum:016x}");
}
if only == "load1" {
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
continue;
}
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
}
for edge in [2560usize, 1600] {
println!(
" {:<36} {}",
format!("embedded srgb, whole file ({edge}):"),
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
);
let quick = lightcraft_engine::files::fs_preview_loader();
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
}
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
let (loader, _) = lightcraft_engine::files::fs_hooks();
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
}
return;
}
if only == "source" {
// opening a photo: where the time goes before the first sharp loupe (per file)
for path in &args {
@@ -152,10 +217,16 @@ fn main() {
Some(a) => println!("gpu: {a} (device + kernels: {:.0} ms)", t.elapsed().as_secs_f64() * 1e3),
None => println!("gpu: none"),
}
let backends: &[bool] = if gpu { &[false, true] } else { &[false] };
// `GPU_ONLY=1`: skip the CPU column (profiling the GPU path)
let backends: &[bool] = match (gpu, std::env::var_os("GPU_ONLY").is_some()) {
(true, true) => &[true],
(true, false) => &[false, true],
_ => &[false],
};
let row = |name: &str, f: &mut dyn FnMut(bool)| {
let cols: Vec<String> = backends.iter().map(|&g| format!("{}", best(n, || f(g)))).collect();
println!("{name:<38} cpu: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
let first = if backends[0] { "gpu" } else { "cpu" };
println!("{name:<38} {first}: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
};
let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box));
let s = typical();
@@ -214,6 +285,68 @@ fn main() {
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
if run("window") {
// a 1:1 zoom window (the loupe's `Slot::Region`): visible 1920×1200 + margin, snapped to the
// 256 px grid, cut from the full-resolution original (`ui-egui` `region::window_for`)
use lightcraft_pipeline::PixelWindow;
let (fw, fh) = (full.width, full.height);
let win = |cx: usize, cy: usize| {
let snap = |v: usize| v / 256 * 256;
let (x, y) = (snap(cx.saturating_sub(960 + 512)), snap(cy.saturating_sub(600 + 512)));
PixelWindow { x, y, w: 3072.min(fw - x), h: 2304.min(fh - y) }
};
let at = |w: PixelWindow, q: Quality| RenderRequest { window: Some(w), quality: q, ..RenderRequest::fit(fw, fh) };
let w0 = win(fw / 2, fh / 2);
println!("window {}×{} at ({}, {}) of {fw}×{fh}", w0.w, w0.h, w0.x, w0.y);
// first window of a photo: nothing cached (the GPU uploads the original: `shared_source`)
row("window first (upload original):", &mut |g| {
lightcraft_engine::gpu::trim_pool(0);
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), g))
});
let wc = [StageCache::default(), StageCache::default()];
for g in backends {
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
}
let mut step = 0usize;
row("window pan (new window, warm src):", &mut |g| {
step += 1;
let w = win(fw / 2 + 512 * (step % 4 + 1), fh / 2);
drop(develop(&full, &info, &s, &at(w, Quality::Full), Some(&wc[g as usize]), g))
});
for g in backends {
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
}
row("window exposure change (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
});
row("window draft exposure drag (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.light.exposure = 0.3 + k * 0.01;
drop(develop(&full, &info, &t, &at(w0, Quality::Draft), Some(&wc[g as usize]), g));
});
row("window NR change (warm):", &mut |g| {
let mut t = s.clone();
k += 1.0;
t.detail.nr_luminance = 30.0 + k % 50.0;
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
});
// `DUMP=file`: the GPU window's raw RGBA bytes (A/B pixel comparisons between builds)
if let (true, Some(path)) = (gpu, std::env::var_os("DUMP")) {
let g = develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), true).image;
let bytes: Vec<u8> = g.data.iter().flatten().copied().collect();
std::fs::write(path, bytes).expect("dump");
}
if gpu && std::env::var_os("GPU_ONLY").is_none() {
let c = develop(&full, &info, &s, &at(w0, Quality::Full), None, false).image;
let g = develop(&full, &info, &s, &at(w0, Quality::Full), None, true).image;
let (max, mean) = diff(&c, &g);
println!("gpu vs cpu, window: max {max} LSB, mean {mean:.4} LSB");
}
}
if run("finish") {
// per-pixel stage cost by feature (warm cache: exposure changes only)
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
@@ -0,0 +1,63 @@
//! What a scan, an import and Synchronize Folder cost before they read the disk, and what
//! naming one photo's sidecar costs, by the size of the library (`N`, default 100,000 photos; the
//! folder looked at holds 500 of them).
//!
//! `N=3500000 cargo run --release -p lightcraft-engine --example scan_setup_bench`
use std::time::Instant;
use lightcraft_catalog::{Op, Photo, Source};
use lightcraft_engine::Session;
fn main() {
let n: u64 = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000);
let mut s = Session::new();
let t = Instant::now();
for chunk in (0..n).collect::<Vec<_>>().chunks(10_000) {
for i in chunk {
let id = s.catalog.alloc_photo_id();
let name = format!("IMG_{i:08}.jpg");
let path = format!("/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day {:05}/extra/{name}", i / 500);
let mut p = Photo::new(id, Source::File { path }, &name, "JPEG", 4032, 3024, "2026-09-30T12:00:00");
p.file_size = 3_400_000 + i;
p.captured = Some(format!("2019-07-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
}
eprintln!("{n} photos built in {:.2} s", t.elapsed().as_secs_f64());
let folder = "/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day 00000".to_string();
for run in 0..3 {
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
let f = lightcraft_catalog::Filter { library_folder: Some(folder.clone()), ..Default::default() };
let ids = s.catalog.query(&f, &lightcraft_catalog::Sort::default());
let query = ms(t);
let t = Instant::now();
let namings = s.sidecar_namings(&ids);
let naming = ms(t);
let t = Instant::now();
let one = ids.first().map(|id| s.sidecar_naming(*id));
let naming_one = ms(t);
let t = Instant::now();
let (input, paths) = lightcraft_engine::import::ScanInput::new(&mut s, std::slice::from_ref(&folder));
let scan = ms(t);
// (the folder is not on this disk: the scan finds no file, and its cost is what it makes of the library)
let t = Instant::now();
let out = lightcraft_engine::import::scan_with(input, &paths, &Default::default());
let scanned = ms(t);
let t = Instant::now();
let job = lightcraft_engine::import::ImportJob::new(&mut s, Default::default());
let import = ms(t);
let t = Instant::now();
let sync = lightcraft_engine::sync::SyncInput::new(&mut s, &folder, false);
let whole = ms(t);
println!(
"N={n} run {run}: query {query:.0} ms ({} ids) | sidecar_namings {naming:.0} ms | sidecar_naming of one {naming_one:.0} ms ({one:?}) | ScanInput::new {scan:.0} ms + scan_with {scanned:.0} ms ({} found) | ImportJob::new {import:.0} ms (ok {}) | SyncInput::new {whole:.0} ms (ok {})",
ids.len(),
out.candidates.len(),
job.is_ok(),
sync.is_ok()
);
let _ = namings;
}
}
+47
View File
@@ -0,0 +1,47 @@
//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and
//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes
//! nothing there, and probes no file (the library is made from the folder's own listing).
//!
//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>`
use std::time::Instant;
use lightcraft_catalog::{Op, Photo, Source};
use lightcraft_engine::Session;
use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with};
fn main() {
let Some(folder) = std::env::args().nth(1) else {
eprintln!("usage: sync_scan_bench <folder>");
return;
};
let mut s = Session::new();
let t = Instant::now();
let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None);
println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64());
for f in &files {
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00");
if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() {
eprintln!("could not add {name}");
}
}
for run in 0..2 {
let input = match SyncInput::new(&mut s, &folder, true) {
Ok(input) => input,
Err(e) => {
eprintln!("{e}");
return;
}
};
let t = Instant::now();
let changes = scan_with(input, &SyncProgress::default());
println!(
"run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)",
t.elapsed().as_secs_f64(),
changes.new.len(),
changes.missing.len(),
changes.metadata.len()
);
}
}
+1 -1
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`, `applePhotos`.
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
+14 -6
View File
@@ -308,9 +308,12 @@ pub enum Outcome {
Imported(Imported),
/// Photos (or osascript) failed: why, for the user.
Failed(String),
/// Photos wasn't asked: the export that reserved the import was cancelled, wrote no files or
/// stopped early.
Skipped(String),
/// Photos wasn't asked: the export that reserved the import was cancelled (`cancelled`), wrote
/// no files or stopped early. `why` is for people; code decides on `cancelled`.
Skipped {
why: String,
cancelled: bool,
},
}
/// One import: reserved by an export still writing its files, running, or finished.
@@ -402,9 +405,12 @@ impl ImportJob {
v["running"] = json!(false);
v["error"] = json!(e);
}
Some(Outcome::Skipped(why)) => {
Some(Outcome::Skipped { why, cancelled }) => {
v["running"] = json!(false);
v["skipped"] = json!(why);
if cancelled {
v["cancelled"] = json!(true);
}
}
}
v
@@ -660,7 +666,7 @@ impl Reservation {
None
};
if let Some(why) = skipped {
job.finish(Outcome::Skipped(why.into()));
job.finish(Outcome::Skipped { why: why.into(), cancelled });
return Err(job.json());
}
if let Err(e) = check(&paths, &self.album) {
@@ -700,7 +706,7 @@ impl Reservation {
impl Drop for Reservation {
fn drop(&mut self) {
if let Some(job) = self.job.take() {
job.finish(Outcome::Skipped("the export ended before writing its files".into()));
job.finish(Outcome::Skipped { why: "the export ended before writing its files".into(), cancelled: false });
}
}
}
@@ -1009,8 +1015,10 @@ mod tests {
// nothing written, or cancelled: Photos isn't asked, and the slot is free again
let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false);
assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}");
assert_eq!(out.get("cancelled"), None, "{out}");
let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true);
assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}");
assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}");
assert_eq!(calls.lock().unwrap().len(), 1);
assert!(imports.running().is_none());
let _ = std::fs::remove_dir_all(dir);
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -25,8 +25,12 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
];
@@ -194,10 +198,12 @@ impl Pool {
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// colour only: geometric lens corrections would stop the sensor proxy from binning
// colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow
// the warp the camera applied to its JPEG)
let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) };
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
let entry = self.models.entry(model.to_owned()).or_default();
entry.0 += 1;
+79 -6
View File
@@ -6,11 +6,14 @@
//! Each browse records when the folders it listed were browsed. Untouched records of folders
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
//! and sidecars on disk are never touched, and browsing the folder again brings them back. A
//! browse also forgets at once the untouched records of the folder's files that are gone from
//! the disk (deleted or moved by another app), so the folder shows what is on disk.
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId};
use lightcraft_catalog::query::in_folder;
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
@@ -42,11 +45,73 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
// 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)?;
let forgotten = forget_gone(s, &dir_s, subfolders, &files);
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
s.keep_active_in_view();
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
Ok(json!({
"path": dir_s,
"subfolders": subfolders,
"photos": shown,
"new": report.imported.len(),
"failed": report.failed.len(),
"forgotten": forgotten,
}))
}
impl Session {
/// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when
/// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a
/// UI that reads it on a worker thread instead. Returns how many went.
pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize {
forget_gone(self, dir, subfolders, files)
}
}
/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by
/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets
/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on
/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those
/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how
/// many went.
fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize {
let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect();
let held = held(s);
let smart = s.media.smart_dir.clone();
let gone: Vec<PhotoId> = s
.catalog
.photos()
.filter(|p| p.local && !p.deleted && !held.contains(&p.id))
// a smart preview may be all that is left of the picture
.filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()))
.filter(|p| match &p.source {
// a file left out of the listing is checked on disk: another spelling of its path, or
// a listing that missed it, keeps it
Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(),
Source::Demo { .. } => false,
})
.map(|p| p.id)
.collect();
if gone.is_empty() {
return 0;
}
let mut forgotten = 0;
for chunk in s.catalog.forgettable_local(&gone).chunks(2000) {
let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() };
match s.catalog.apply(op.clone()) {
Ok(_) => {
s.pending_log.push(op);
forgotten += chunk.len();
}
Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"),
}
}
if forgotten > 0 {
log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}");
}
forgotten
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
@@ -82,7 +147,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
let mut keep = held(s);
keep.extend(s.visible().iter().copied());
keep
}
/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo
/// / redo step.
fn held(s: &Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new();
keep.extend(s.selection.ids.iter().copied());
keep.extend(s.selection.active);
keep.extend(s.previous_active);
@@ -151,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
/// The browsed photos among `ids` (default: the selection) become library photos.
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
if ids.is_empty() {
return Ok(json!({"added": 0}));
}
@@ -337,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
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);
let ids = s.targets(p)?;
if ids.is_empty() {
return Err(bad(C, "no photos to move: select some or give ids"));
}
+1 -1
View File
@@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result<Value> {
}
fn index(p: &Value, c: &str) -> Result<usize> {
Ok(f64_req(p, "index", c)? as usize)
super::index_req(p, "index", c)
}
pub fn specs() -> Vec<CommandSpec> {
+72 -27
View File
@@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
crate::camera_preview::with_starting_colour(&mut raw, &bytes);
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
.map_err(|e| e.to_string())?;
@@ -58,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.convertToDng";
let ids = s.targets(p);
let ids = s.targets(p)?;
let mut ops = Vec::new();
let mut converted = Vec::new();
let mut skipped = Vec::new();
@@ -124,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
/// 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 mut ids = s.targets(p);
let mut ids = s.targets(p)?;
ids.dedup();
if ids.is_empty() {
return Err(bad(C, "no active photo"));
@@ -201,12 +202,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a
/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed
/// again (see [`lightcraft_catalog::Photo::preview_checked`]).
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only);
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
&& info.preview_only == ph.preview_only
&& preview_checked == ph.preview_checked;
(!same).then_some(Op::SetContent {
id,
width: info.width,
@@ -214,6 +219,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
preview_checked,
})
}
@@ -234,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
m.aperture = m.aperture.or(src.aperture);
m.iso = m.iso.or(src.iso);
m.gps = m.gps.or(src.gps);
// the camera's focus point: photos imported before it was read get it here
if m.focus.is_none() {
m.focus = src.focus.clone();
}
let mut ops = Vec::new();
if m != before {
ops.push(Op::SetMeta { id, meta: Box::new(m) });
@@ -271,10 +281,41 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
pub(crate) fn reload_ops(
cat: &lightcraft_catalog::Catalog,
id: lightcraft_catalog::PhotoId,
info: &crate::media::ProbeInfo,
ops: &mut Vec<Op>,
changed: &mut Vec<lightcraft_catalog::PhotoId>,
) {
let Some(ph) = cat.photo(id) else { return };
// 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)).chain(as_shot_wb_op(id, ph, info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
changed.push(id);
}
// virtual copies share the file
for c in cat.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)).chain(as_shot_wb_op(c.id, c, info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
changed.push(c.id);
}
}
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.targets(p);
let ids = s.targets(p)?;
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
@@ -286,25 +327,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// 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)).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)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
@@ -320,7 +344,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.duplicate";
let mut made = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
let src = Path::new(path);
@@ -421,6 +445,27 @@ mod tests {
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
}
/// Reload from Disk gives a photo imported before focus points were read its camera focus point,
/// and never replaces one it has.
#[test]
fn reload_fills_a_missing_focus_point() {
let fp = |x: f64| lightcraft_meta::FocusPoint {
rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 },
mode: Some("AF-C".into()),
area: None,
tracking: None,
};
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00");
ph.captured = Some("2026-10-04T08:16:11".into());
let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() };
info.meta.focus = Some(fp(0.3));
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
assert_eq!(meta.focus, Some(fp(0.3)));
ph.meta.focus = Some(fp(0.1));
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept");
}
fn lens() -> lightcraft_develop::EmbeddedLens {
lightcraft_develop::EmbeddedLens {
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
@@ -494,7 +539,7 @@ mod tests {
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
let f = root.join("raw/rw2-panasonic-gx80.rw2");
if !f.exists() {
eprintln!("skip: {} absent", f.display());
lightcraft_testkit::skip!(Corpus, "{} absent", f.display());
return;
}
let mut s = Session::new().with_fs();
+1 -1
View File
@@ -20,7 +20,7 @@ fn secs(iso: &str) -> Option<i64> {
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
+3 -4
View File
@@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
if chosen.is_empty() {
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
let target = path.to_string_lossy();
s.check_write_target(&target).map_err(|e| bad(C, e))?;
crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
+14 -3
View File
@@ -303,9 +303,20 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
}
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
let sibling = src.with_file_name("denoise-model.json");
let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else {
let why = if sibling.is_file() {
"denoise-model.json could not be read or is too large"
} else if src.with_file_name("model.json").is_file() {
// other apps ship their models with a model.json of their own (#1381)
"No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \
does not read: it needs its own denoise-model.json (see docs/denoise.md)"
} else {
"No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)"
};
return Err(bad(C, why));
};
let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?;
let dir = models_dir(s, C)?;
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
+107 -35
View File
@@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
use crate::{Result, Session, media::SourceLevel};
fn active(s: &Session, c: &str) -> Result<PhotoId> {
@@ -26,8 +26,11 @@ fn section_param(p: &Value, c: &str) -> Result<Section> {
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
}
fn groups_param(p: &Value) -> Option<Vec<SettingsGroup>> {
p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
fn groups_param(p: &Value, c: &str) -> Result<Option<Vec<SettingsGroup>>> {
match p.get("groups") {
None | Some(Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(c, format!("invalid `groups`: {e}"))),
}
}
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
@@ -141,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre
/// Change version `p.index` of the active photo (`f` gets it and the current settings).
fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> {
let id = active(s, c)?;
let i = f64_req(p, "index", c)? as usize;
let i = index_req(p, "index", c)?;
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let mut versions = ph.versions.clone();
let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?;
@@ -251,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
}),
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
let ids = s.targets(p);
let ids = s.targets(p)?;
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -301,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> {
let targets = match named_photos(s, p, C)? {
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
Some(ids) => ids,
None => s.targets(p),
None => s.targets(p)?,
};
let ops: Vec<Op> = targets
.into_iter()
@@ -497,13 +500,15 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| {
let ch = str_param(p, "channel").unwrap_or("master").to_string();
let pts: Vec<Point> = p
let mut pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| bad("develop.curve", "missing points"))?
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?.clamp(0.0, 1.0), q.get(1)?.as_f64()?.clamp(0.0, 1.0))))
.collect();
// stored in the order the curve runs, as the editor draws and drags them
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
edit(s, "develop.curve", "Tone Curve", |d| {
match ch.as_str() {
"master" | "rgb" => d.curve.master = pts,
@@ -583,14 +588,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Straighten",
[],
None,
"{angle: degrees -45..45} — keeps the largest crop of the current aspect",
"{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)",
has_active,
|s, p| {
let id = active(s, "crop.straighten")?;
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| {
// keep the current crop if it still fits, else the largest of its aspect that does
// keep the current crop if it still fits, else shrink it about its centre until it does
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
Ok(())
})
@@ -673,10 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
let (w, h) = crop_dims(s, id);
edit(s, "crop.rotateAspect", "Rotate Crop", |d| {
let r = d.crop.geometry.rect_px(w, h);
let a = r.height() / r.width();
// a degenerate crop has no aspect to rotate: fall back to the image's own
let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0);
d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x));
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, Some(a));
d.crop.geometry = crop_fit_angle(w, h, angle, a);
Ok(())
})
}),
@@ -782,8 +788,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
has_active,
|s, p| {
let id = active(s, "develop.copy")?;
if let Some(g) = groups_param(p) {
let c = "develop.copy";
let id = active(s, c)?;
if let Some(g) = groups_param(p, c)? {
s.copy_groups = g;
}
let d = s.develop_of(id).unwrap_or_default();
@@ -802,9 +809,9 @@ pub fn specs() -> Vec<CommandSpec> {
let c = "develop.pastePrevious";
let prev =
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect();
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -823,14 +830,15 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
has_clipboard,
|s, p| {
let c = "develop.paste";
let mut clip = s.clipboard.clone().unwrap_or_default();
if let Some(only) = groups_param(p) {
if let Some(only) = groups_param(p, c)? {
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
clip = lightcraft_develop::extract_groups(&full, &only);
}
let ids = s.targets(p);
let ids = s.targets(p)?;
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -849,8 +857,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{groups?} — copy the active photo's settings to all selected photos",
has_selection,
|s, p| {
let id = active(s, "develop.sync")?;
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let c = "develop.sync";
let id = active(s, c)?;
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let partial = lightcraft_develop::extract_groups(&d, &groups);
let ops = s
@@ -881,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
let delta = f64_req(p, "delta", c)?;
let label = format!("Quick Develop: {}", spec.label);
let ops: Vec<Op> = s
.targets(p)
.targets(p)?
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
@@ -934,7 +943,7 @@ pub fn specs() -> Vec<CommandSpec> {
};
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect();
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
for id in ids {
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
@@ -969,7 +978,8 @@ pub fn specs() -> Vec<CommandSpec> {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?;
let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;
let ids = s.targets(p);
// `id` names the preset here: the photos are `ids`, else the selection
let ids = s.targets(&json!({"ids": p.get("ids")}))?;
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -979,10 +989,11 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
let id = active(s, "preset.create")?;
let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
let c = "preset.create";
let id = active(s, c)?;
let name = str_param(p, "name").ok_or_else(|| bad(c, "missing name"))?;
let group = str_param(p, "group").unwrap_or("User Presets");
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
@@ -1026,7 +1037,7 @@ pub fn specs() -> Vec<CommandSpec> {
let c = "preset.update";
let id = active(s, c)?;
let current = s.develop_of(id).unwrap_or_default().to_json();
let groups = groups_param(p);
let groups = groups_param(p, c)?;
let pr = user_preset(s, p, c)?;
pr.settings = match groups {
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
@@ -1051,14 +1062,14 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.restore")?;
let i = f64_req(p, "index", "version.restore")? as usize;
let i = index_req(p, "index", "version.restore")?;
let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?;
s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?;
ok()
}),
cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.delete")?;
let i = f64_req(p, "index", "version.delete")? as usize;
let i = index_req(p, "index", "version.delete")?;
let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default();
if i >= versions.len() {
return Err(bad("version.delete", "no such version"));
@@ -1079,15 +1090,47 @@ pub fn specs() -> Vec<CommandSpec> {
let id = active(s, "history.clear")?;
let d = s.develop_of(id).unwrap_or_default();
let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }];
s.commit("Clear History", Op::SetHistory { id, history })?;
s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?;
ok()
}),
cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| {
let id = active(s, "history.restore")?;
let i = f64_req(p, "index", "history.restore")? as usize;
let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?;
s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?;
ok()
cmd!(
"history.restore",
"Go to History Step",
[],
None,
"{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit",
has_active,
|s, p| {
let c = "history.restore";
let id = active(s, c)?;
let i = f64_req(p, "index", c)?;
if !(i.is_finite() && i >= 0.0) {
return Err(bad(c, "no such step"));
}
go_to_history_step(s, c, id, i as usize)
}
),
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
let c = "history.previous";
let id = active(s, c)?;
let i = s
.catalog
.photo(id)
.and_then(|ph| ph.history_index())
.and_then(|i| i.checked_sub(1))
.ok_or_else(|| bad(c, "at the first History step"))?;
go_to_history_step(s, c, id, i)
}),
cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| {
let c = "history.next";
let id = active(s, c)?;
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let i = ph
.history_index()
.map(|i| i.saturating_add(1))
.filter(|i| *i < ph.history.len())
.ok_or_else(|| bad(c, "at the latest History step"))?;
go_to_history_step(s, c, id, i)
}),
// ---- interactions (slider drags, brush strokes)
cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| {
@@ -1104,3 +1147,32 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later
/// steps stay listed (one undo step).
fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result<Value> {
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?;
let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i);
let label = format!("History: {}", st.label);
let edited = Some((s.clock)());
s.commit(
&label,
Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] },
)?;
ok()
}
fn has_history_before(s: &Session) -> std::result::Result<(), String> {
let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index());
if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) }
}
fn has_history_after(s: &Session) -> std::result::Result<(), String> {
let photo = s.active().and_then(|id| s.catalog.photo(id));
if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) {
Ok(())
} else {
Err("at the latest History step".into())
}
}
+183 -21
View File
@@ -17,8 +17,8 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use crate::{Result, Session};
use super::{CommandSpec, always, bad, cmd, has_selection};
use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
/// Regions made by this command say so in their description; only those are replaced on a new run.
@@ -34,15 +34,41 @@ const EXISTING_IOU: f64 = 0.3;
/// What to tell someone who has not downloaded the detector yet.
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
/// Why [`detector`] has no detector to give.
#[derive(Debug)]
pub(crate) enum NoDetector {
/// Not downloaded yet: a frontend offers the download ([`crate::ErrorKind::ModelMissing`]).
NotInstalled,
/// Nowhere to keep it, or there but unusable: says what to do.
Unusable(String),
}
impl NoDetector {
/// Command `cmd`'s error for it. The message reads as before kinds existed (an invalid-parameters
/// error), so control-channel and MCP clients see the same `error` text.
pub(crate) fn into_error(self, cmd: &str) -> EngineError {
match self {
Self::NotInstalled => EngineError::ModelMissing { model: AiModel::FaceDetector, msg: bad(cmd, NOT_INSTALLED).to_string() },
Self::Unusable(why) => bad(cmd, why),
}
}
}
/// The YuNet loaded last, and from where.
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String> {
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID);
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
let home = s
.face_models_dir
.as_deref()
.ok_or_else(|| NoDetector::Unusable("this build has nowhere to keep face models (the desktop app does)".into()))?
.join(YUNET_ID);
let path = home.join("model.onnx");
// installing writes the manifest last, so a folder without one is not (yet) a model
if !path.is_file() || !home.join("face-model.json").is_file() {
return Err(NOT_INSTALLED.into());
return Err(NoDetector::NotInstalled);
}
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((p, d)) = loaded.as_ref()
@@ -50,16 +76,141 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String
{
return Ok(d.clone());
}
let len = std::fs::metadata(&path).map_err(|e| format!("could not read the detector: {e}"))?.len();
let len = std::fs::metadata(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?.len();
if len > MAX_MODEL_BYTES {
return Err("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into());
return Err(NoDetector::Unusable("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into()));
}
let bytes = std::fs::read(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?;
let d = Arc::new(
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
);
#[cfg(not(target_arch = "wasm32"))]
{
let net = d.clone();
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
}
let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?;
let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?);
*loaded = Some((path, d.clone()));
Ok(d)
}
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
#[derive(Clone, Debug, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GpuState {
/// The background check is still running.
pub checking: bool,
/// The GPU it runs on; `None` when it runs on the CPU.
pub device: Option<String>,
/// Why it runs on the CPU.
pub why: Option<String>,
}
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
}
fn set_gpu_state(what: &'static str, st: GpuState) {
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
match all.iter_mut().find(|(w, _)| *w == what) {
Some((_, old)) => *old = st,
None => all.push((what, st)),
}
}
/// The user chose the CPU for face models (Settings ▸ Faces).
pub(crate) fn cpu_only(s: &Session) -> bool {
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
}
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
/// The check shows in the activity stack while it runs.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn offer_gpu(
s: &Session,
what: &'static str,
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
) {
if cpu_only(s) {
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
return;
}
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
task.detail(what);
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
let _task = task;
let gpu = match lightcraft_gpu::faces::accelerator() {
Ok(gpu) => gpu,
Err(why) => {
log::info!("faces: {what} runs on the CPU ({why})");
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
};
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
match attach(gpu) {
Ok(()) => {
log::info!("faces: {what} runs on {device}");
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
}
Err(why) => {
log::info!("faces: {what} stays on the CPU: {why}");
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
}
});
if let Err(e) = spawned {
log::warn!("faces: could not check the GPU for {what}: {e}");
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
}
}
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.gpu";
if let Some(cpu) = p.get("cpuOnly") {
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
super::face_models::set_cpu_only(&dir, cpu)?;
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
#[cfg(not(target_arch = "wasm32"))]
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
#[cfg(target_arch = "wasm32")]
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
if cpu {
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
if let Some(net) = net {
net.detach();
}
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
}
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(d) = detector {
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
}
if let Some(e) = embedder {
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
}
}
}
}
Ok(gpu_json(s))
}
/// What `faces.gpu` and `faces.models.list` say about the GPU.
pub(crate) fn gpu_json(s: &Session) -> Value {
json!({
"cpuOnly": cpu_only(s),
"detection": gpu_state("detection"),
"recognition": gpu_state("recognition"),
})
}
fn face_json(f: &Face) -> Value {
json!({
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
@@ -103,10 +254,10 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),
max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),
};
let detector = detector(s).map_err(|e| bad(C, e))?;
let detector = detector(s).map_err(|e| e.into_error(C))?;
let started = Instant::now();
let (mut results, mut ops) = (Vec::new(), Vec::new());
for id in s.targets(p) {
for id in s.targets(p)? {
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
Ok(r) => r,
Err(e) => {
@@ -147,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
)]
vec![
cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
),
cmd!(
query "faces.gpu",
"Face Models on the GPU",
[],
None,
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
always,
gpu
),
]
}
#[cfg(test)]
+12
View File
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
pub(super) enabled: bool,
/// The installed recogniser in use.
pub(super) embedder: Option<String>,
/// Keep every face model on the CPU, even when the GPU would be faster.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub(super) cpu_only: bool,
}
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
})
}
/// Keep the face models on the CPU (or let them use the GPU again).
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
let mut st = read_settings(dir);
st.cpu_only = on;
write_settings(dir, &st)
}
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
"dir": dir.as_ref().map(|d| d.display().to_string()),
"enabled": settings.enabled,
"embedder": settings.embedder,
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
"gpu": super::face_detect::gpu_json(s),
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
"runtime": cfg!(not(target_arch = "wasm32")),
"models": models,
+148 -7
View File
@@ -1,10 +1,11 @@
//! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed
//! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every
//! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is
//! what teaches the suggestions).
//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two
//! people when asked.
//!
//! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is
//! catalog-only (no sidecar is written) and `faces.setName` is undoable.
//! catalog-only (no sidecar is written) and naming is undoable.
use lightcraft_catalog::Op;
use serde_json::{Value, json};
@@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id.0, "index": index, "name": name}))
}
/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo
/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused
/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing.
fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.renamePerson";
let from = match p.get("from") {
Some(Value::String(v)) => {
let t = v.trim();
if t.is_empty() {
return Err(bad(C, "missing `from`"));
}
t.to_string()
}
Some(_) => return Err(bad(C, "`from` must be text")),
None => return Err(bad(C, "missing `from`")),
};
let to = match p.get("to") {
Some(Value::String(v)) => {
let t = v.trim();
if t.is_empty() {
return Err(bad(C, "`to` must be a name"));
}
if t.chars().count() > 200 || t.chars().any(char::is_control) {
return Err(bad(C, "a name is at most 200 characters, without control characters"));
}
t.to_string()
}
Some(_) => return Err(bad(C, "`to` must be text")),
None => return Err(bad(C, "missing `to`")),
};
let merge = super::bool_or(p, "merge", false);
let from_key = from.to_lowercase();
let to_key = to.to_lowercase();
let mut matched = 0usize;
let mut other = false;
let mut faces = 0usize;
let mut ops = Vec::new();
let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect();
for id in ids {
let Some(ph) = s.catalog.photo(id) else { continue };
let mut indexes = Vec::new();
for (i, r) in ph.meta.regions.iter().enumerate() {
if r.kind != lightcraft_meta::RegionKind::Face {
continue;
}
let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
let key = n.to_lowercase();
if key == from_key {
matched += 1;
if r.name.as_deref() != Some(to.as_str()) {
indexes.push(i);
}
} else if key == to_key {
other = true;
}
}
if indexes.is_empty() {
continue;
}
let mut meta = ph.meta.clone();
for i in indexes {
if let Some(region) = meta.regions.get_mut(i) {
region.name = Some(to.clone());
faces += 1;
}
}
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
}
if matched == 0 {
return Err(bad(C, format!("no person named {from}")));
}
if faces == 0 {
return Err(bad(C, format!("{to} is already their name")));
}
if other && !merge {
return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them")));
}
let photos = ops.len();
s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?;
s.skip_auto_write = true;
if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {
s.filter.person = Some(to.clone());
}
Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other}))
}
/// Most faces `faces.unnamed` lists.
const MAX_UNNAMED: usize = 3000;
@@ -234,6 +321,8 @@ mod imp {
return Ok(e.clone());
}
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
let net = embedder.clone();
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
let dim = match installed.manifest.output {
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
@@ -275,7 +364,7 @@ mod imp {
}
let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?;
let preview = s.scan_preview_of(&photo);
Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector })
Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector })
}
/// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a
@@ -299,13 +388,16 @@ mod imp {
}
}
/// Take a finished photo into the index (ignored when it was made for another model or another library). Returns
/// (embedded, failed).
/// Take a finished photo into the index (ignored when it was made for another model or another library, or from
/// a file the photo no longer has: issue #1524). Returns (embedded, failed).
fn ingest(s: &mut Session, d: Done) -> (usize, usize) {
s.faces.in_flight.remove(&d.id);
if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag {
return (0, 0);
}
if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) {
return (0, 0);
}
if d.detect_wanted {
match &d.found {
Some(found) => {
@@ -582,6 +674,9 @@ mod imp {
if let Some(w) = &s.faces.worker {
w.allow(target, pace);
}
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
let (runs, share) = crate::faces_worker::gpu_pace(pace);
lightcraft_gpu::faces::set_pace(runs, share);
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
let Some(id) = s.faces.queue.pop() else { break };
let Some(job) = prepare(s, id, true) else { continue };
@@ -751,7 +846,7 @@ mod imp {
const C: &str = "faces.suggest";
let embedder = current(s, C)?;
collect(s);
let targets = s.targets(p);
let targets = s.targets(p)?;
let model_threshold = embedder.manifest().thresholds.match_cosine;
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32);
let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD);
@@ -830,7 +925,8 @@ mod imp {
}
fn finished(s: &Session, id: PhotoId, found: Option<Vec<Rect>>, results: Vec<(Rect, Option<Vec<f32>>, Rect)>) -> Done {
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found }
let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default();
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found }
}
#[test]
@@ -915,6 +1011,42 @@ mod imp {
let _ = std::fs::remove_dir_all(&d);
}
/// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from
/// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is
/// searched again (issue #1524).
#[test]
fn faces_made_from_a_replaced_file_are_dropped() {
let (mut s, d) = loaded("replaced");
let (a, b) = two_photos(&s);
let rect = region(0.2, None).rect;
s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)]));
s.faces.scanned.insert(a.0);
s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)]));
s.faces.scanned.insert(b.0);
let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]);
let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap();
let replace = Op::SetContent {
id: a,
width,
height,
file_size: 1234,
content_hash: Some("edited".into()),
preview_only: None,
preview_checked: String::new(),
};
s.commit("Reload Changed Files", replace).unwrap();
assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten");
assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again");
assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs");
assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped");
assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0));
// what is made from the new file is taken in
let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]);
assert_eq!(ingest(&mut s, new), (1, 0));
assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_unnamed_faces_that_look_like_a_person_come_best_first() {
let (mut s, d) = loaded("similar");
@@ -1047,5 +1179,14 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
set_name
),
cmd!(
"faces.renamePerson",
"Rename Person",
[],
None,
"{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched",
super::always,
rename_person
),
]
}
+50
View File
@@ -0,0 +1,50 @@
//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the
//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`.
use serde_json::Value;
use super::{CommandSpec, cmd};
use crate::{Result, Session};
#[cfg(not(target_arch = "wasm32"))]
fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
crate::focus_scan::read_points(s, p)
}
#[cfg(target_arch = "wasm32")]
fn read_points(_: &mut Session, _: &Value) -> Result<Value> {
Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk"))
}
#[cfg(not(target_arch = "wasm32"))]
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
crate::focus_scan::pump(s, p)
}
#[cfg(target_arch = "wasm32")]
fn pump(_: &mut Session, _: &Value) -> Result<Value> {
Ok(serde_json::json!({"active": false}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.readFocusPoints",
"Read Focus Points",
["Photo"],
None,
"{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)",
super::always,
read_points
),
cmd!(
query "focus.pump",
"Read Focus Points in the Background",
[],
None,
"{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at",
super::always,
pump
),
]
}
+1 -1
View File
@@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
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 ids = s.targets(p)?;
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
.filter_map(|id| {
+3 -3
View File
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
return Ok(json!({"changed": 0}));
};
let ids = s.targets(p);
let ids = s.targets(p)?;
let all = !ids.is_empty()
&& ids.iter().all(|id| {
s.catalog
@@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
|s, p| {
let mut current: Vec<String> = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
if let Some(ph) = s.catalog.photo(id) {
current.extend(ph.meta.keywords.iter().cloned());
}
@@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(parent) => format!("{parent}|{name}"),
None => name,
});
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() };
let op = s
.catalog
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
+191 -32
View File
@@ -167,9 +167,22 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re
Ok((kept, skipped))
}
/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep
/// showing `i/n` (issue #1493).
fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> {
let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
let n = ops.len();
if n == 0 {
return Ok(0);
}
ops.extend(s.catalog.remove_from_stacks_ops(ids));
s.commit(label, Op::Batch { ops })?;
Ok(n)
}
/// 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);
let targets = s.targets(p)?;
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
let n = ops.len();
if n > 0 {
@@ -181,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
if rating != old {
@@ -196,6 +209,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
Ok(json!({"changed": n}))
}
/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged,
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p)? {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
let flag = step(old);
if flag != old {
ops.push(Op::SetFlag { id, flag });
}
}
let n = ops.len();
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
@@ -219,13 +251,23 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
Ok(json!({"active": vis[next].0}))
}
/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input
/// can't overflow).
fn row_step(p: &Value) -> isize {
p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize
}
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
let targets = s.targets(p);
let targets = s.targets(p)?;
let mut ops = Vec::new();
for id in targets {
if let Some(d) = s.develop_of(id) {
let mut d = (*d).clone();
d.orientation = d.orientation.rotated(cw);
// the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo)
let turn = lightcraft_geom::Orientation::Normal.rotated(cw);
d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect);
d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w));
ops.extend(s.develop_op(id, d, "Rotate"));
}
}
@@ -235,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
}
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let targets = s.targets(p);
let targets = s.targets(p)?;
let mut ops = Vec::new();
for id in targets {
if let Some(d) = s.develop_of(id) {
@@ -356,11 +398,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
Ok(json!({"count": vis.len()}))
s.keep_active_in_view();
Ok(json!({"count": s.visible().len()}))
}
),
cmd!(
@@ -430,8 +469,7 @@ pub fn specs() -> Vec<CommandSpec> {
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
}
let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
s.commit("Remove Folder from Library", Op::Batch { ops })?;
mark_deleted(s, &ids, "Remove Folder from Library")?;
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
s.filter.library_folder = None;
}
@@ -504,6 +542,44 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
cmd!(
"library.refresh",
"Refresh View",
["View"],
None,
"{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed",
always,
|s, _| {
s.refresh_view();
ok()
}
),
cmd!(
"library.holdView",
"Hold View Until Refreshed",
["View"],
None,
"{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing",
always,
|s, p| {
let on = match p.get("on") {
None | Some(Value::Null) => !s.hold_view,
Some(Value::Bool(on)) => *on,
Some(_) => return Err(bad("library.holdView", "on must be true or false")),
};
if on != s.hold_view {
let was = s.hold_view;
s.hold_view = on;
if let Err(e) = s.save_prefs() {
// not kept: not changed either, so asking again does what was asked
s.hold_view = was;
return Err(e);
}
s.refresh_view();
}
Ok(json!({"hold": s.hold_view}))
}
),
cmd!(
"library.shuffle",
"Reshuffle",
@@ -641,6 +717,24 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),
cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)),
cmd!(
"library.down",
"Photo Below",
[],
Some("Down"),
"{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}",
always,
|s, p| { step(s, row_step(p)) }
),
cmd!(
"library.up",
"Photo Above",
[],
Some("Up"),
"{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}",
always,
|s, p| { step(s, -row_step(p)) }
),
// ---- rating / flags / labels
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
@@ -663,6 +757,40 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
cmd!(
"photo.cycleFlag",
"Cycle Flag",
["Photo", "Set Flag"],
Some("`"),
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
has_selection,
|s, p| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
})
}
),
cmd!(
"photo.increaseFlag",
"Increase Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Up"),
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
has_selection,
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
),
cmd!(
"photo.decreaseFlag",
"Decrease Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Down"),
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
has_selection,
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
@@ -755,9 +883,10 @@ pub fn specs() -> Vec<CommandSpec> {
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let targets = s.targets(p)?;
let n = mark_deleted(s, &targets, "Delete")?;
select_after_deletion(s, &before);
Ok(v)
Ok(json!({"changed": n}))
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
id,
@@ -783,7 +912,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let before = s.visible_cloned();
let t = s.targets(p);
let t = s.targets(p)?;
let op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
select_after_deletion(s, &before);
@@ -800,7 +929,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let strs =
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
let targets = s.targets(p);
let targets = s.targets(p)?;
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
None => None,
@@ -898,8 +1027,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
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 (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) };
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
s.commit(
@@ -1139,7 +1267,7 @@ pub fn specs() -> Vec<CommandSpec> {
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 targets = ids_param(p).unwrap_or_else(|| s.selected());
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"));
@@ -1147,8 +1275,10 @@ pub fn specs() -> Vec<CommandSpec> {
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
let mut photos = al.photos.clone();
let before = photos.len();
// looked up, not searched for: adding thousands to a big album stays linear
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
for t in targets {
if !photos.contains(&t) {
if held.insert(t) {
photos.push(t);
}
}
@@ -1166,7 +1296,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{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 = album_targets_or_err(s, "album.toggleTarget", 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.selected()))?;
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()) {
@@ -1179,11 +1309,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
// looked up, not searched for: thousands toggled in a big album stay linear
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
let photos: Vec<PhotoId> = if all_in {
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
} else {
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
};
let name = al.name.clone();
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
@@ -1310,17 +1443,39 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(result)
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
cmd!(
"album.removePhotos",
"Remove from Album",
["Photo"],
None,
"{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}",
has_selection,
|s, p| {
let id = match p.get("id") {
Some(_) => album_param(p, "id", "album.removePhotos")?,
None => match s.source {
LibrarySource::Album(a) => a,
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
},
};
let targets = ids_param(p).unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
}
if al.folder {
return Err(bad("album.removePhotos", "album folders don't hold photos"));
}
let name = al.name.clone();
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
let removed = al.photos.len().saturating_sub(photos.len());
if removed > 0 {
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
}
Ok(json!({"removed": removed, "album": name}))
}
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
ok()
}),
),
cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| {
let id = album_param(p, "id", "album.setCover")?;
let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active());
@@ -1599,3 +1754,7 @@ mod gps_tests {
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
#[cfg(test)]
#[path = "library/tests_flag_cycle.rs"]
mod flag_cycle_tests;
@@ -0,0 +1,100 @@
//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine.
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-13",
);
photo.flag = flag;
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
Ok(s)
}
fn flags(s: &Session) -> Vec<Flag> {
(1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect()
}
#[test]
fn cycle_steps_unflagged_pick_reject_and_wraps() {
let mut s = session().unwrap();
s.execute("library.select", &json!({"ids": [1]})).unwrap();
let seen: Vec<Flag> = (0..4)
.map(|_| {
s.execute("photo.cycleFlag", &json!({})).unwrap();
flags(&s)[0]
})
.collect();
assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]);
}
#[test]
fn cycle_uses_each_photos_own_flag_in_one_undo_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3);
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
}
#[test]
fn explicit_ids_active_fallback_advance_and_errors() {
let mut s = session().unwrap();
s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]);
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
s.execute("photo.cycleFlag", &json!({})).unwrap();
assert_eq!(flags(&s)[1], Flag::Reject);
s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap();
assert_eq!(flags(&s)[1], Flag::None);
assert_eq!(s.active(), Some(PhotoId(3)));
let undo = s.undo.len();
assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err());
assert_eq!(flags(&s)[0], Flag::None);
assert_eq!(s.undo.len(), undo);
s.selection = Selection::default();
assert!(s.execute("photo.cycleFlag", &json!({})).is_err());
assert_eq!(s.undo.len(), undo);
}
#[test]
fn increase_and_decrease_clamp_without_wrapping() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2);
assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]);
assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]);
let undo = s.undo.len();
assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0);
assert_eq!(s.undo.len(), undo, "no change, no history step");
}
#[test]
fn registered_with_classic_shortcuts() {
let specs = crate::command_specs();
let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(key("photo.cycleFlag"), Some("`"));
assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up"));
assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down"));
}
+3
View File
@@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
imported.push(json!({"id": id, "name": display, "group": group}));
}
s.save_prefs()?;
// presets that name these profiles use them now
crate::profile_refs::refresh(s);
Ok(json!({"imported": imported, "failed": failed}))
}
@@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
lightcraft_pipeline::lut::unregister(&id);
s.save_prefs()?;
crate::profile_refs::refresh(s);
Ok(json!({"deleted": id}))
}),
]
+3 -3
View File
@@ -10,7 +10,7 @@ use crate::Result;
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
Ok((s.targets(p), template, start))
Ok((s.targets(p)?, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
@@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
let mut ops = Vec::new();
let mut photos = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let Some(ph) = s.catalog.photo(id) else { continue };
if ph.meta.gps.is_some() && !replace {
has_gps += 1;
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
let c = "photo.setCaptureTime";
let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
if targets.is_empty() {
return Err(bad(c, "no photos"));
}
Loaded 100 of 584 files, more files were not shown because too many files have changed in this diff. Show more