Compare commits

...
1078 Commits
Author SHA1 Message Date
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 <noreply@anthropic.com>
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
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
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
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
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
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
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
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-10-12 21:15:57 -04: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
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <codex@openai.com>
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 <noreply@openai.com>
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 <noreply@openai.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-10-12 19:02:19 +02: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
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 <noreply@anthropic.com>
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
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <codex@openai.com>
Signed-off-by: Christos Sidiropoulos <dev@csidirop.de>
2026-10-12 17:15:54 +02:00
Brandon ThomasandClaude Opus 5.5 701f78ea2e Release 0.7.1: fix the macOS build
eframe 0.36's App::on_exit takes the glow context as a second argument.
The macOS-only menu-bar Quit path still called on_exit(self), so the
0.7.0 macOS release job failed with E0061. Pass None; our on_exit ignores
it. macOS isn't built in CI, so only the release build caught it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #954

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

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

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

Sources: own design.

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

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

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

Fixes #582.

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

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

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

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

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

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

Fixes #611. Sources: own design.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Sources: own design.

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

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

Sources: own design.

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

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

Sources: own design.

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

Sources: own design.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #614

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:35:20 +09:00
Brandon ThomasandClaude Opus 5.5 2ffe2bcea2 LR-BEHAV-BG-TASKS: the preview-build activity test builds small previews (timed out on a loaded machine)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:35:13 -04:00
Ikki NishiharaandClaude Sonnet 5.5 a4436597ad DNG: cap the pixels the bad-pixel opcodes repair
FixBadPixelsConstant listed every pixel equal to its constant, and the
FixBadPixelsList cap was checked only after a whole rectangle was pushed.
Cap both at the number the list arm already used (1 << 22).

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:52:53 -04:00
Brandon Thomas c606bb4b0e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity.md
2026-10-10 03:52:44 -04:00
Brandon Thomas 4a25abdfa8 Merge PR #480 2026-10-10 03:47:10 -04:00
Brandon Thomas dd2e916842 Merge PR #555
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-10 03:47:10 -04:00
Brandon Thomas eb2a0fde05 Merge PR #560
# Conflicts:
#	crates/engine/src/media.rs
2026-10-10 03:47:10 -04:00
Brandon ThomasandClaude Opus 5.5 fb6dda44ae LR-IMP-FORMATS: merge main; reconcile camera-TIFF detection with the Samsung/Pentax rules (#281)
Merge origin/main into #480 and apply the review:
- the Samsung-with-a-Pentax-maker-note -> PEF rule now also needs raw evidence; its test fixture
  gets a CFA photometric, and the same RGB file without it is not a raw;
- for the Pentax family the Exif CFAPattern (0xa302) counts as raw evidence, as main's PEF reader
  takes its layout from it (uncompressed / PackBits PEFs);
- Samsung's private raw compressions 32769..=32773 count for SRW (NX1 / NX500 use 32773);
- the engine fallback in files.rs is dropped (main's version kept): a corrupt raw without a
  preview stays an import error; the engine regression test for camera-authored TIFFs is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:46:23 -04:00
Brandon Thomas 0f33d2b14a Merge PR #601 2026-10-10 03:45:41 -04:00
Brandon ThomasandClaude Opus 5.5 3752207ad1 LR-BEHAV-BG-TASKS: the preview-build activity test builds small previews (timed out on a loaded machine)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:45:37 -04:00
Brandon ThomasandClaude Opus 5.5 7987783a57 LR-IMP-FORMATS: merge main; record the selection-value-8 rule's provenance
Merge origin/main into #555. The decode_hasselblad doc comment records how the coding rule was
established (first hypothesis recalled by the AI author from training data that includes other
converters, corrected for byte order and confirmed by a black-box search over 17,280 layout
hypotheses on the CC0 files; the code is original). The LR-IMP-FORMATS row on main's text now
lists the decoded FFF and lossless-JPEG 3FR files instead of preview-only. RENDER_CACHE_VERSION
37 -> 38.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:39:41 -04:00
Brandon Thomas cf7b55ace8 Merge PR #602
# Conflicts:
#	crates/engine/src/media.rs
2026-10-10 03:38:38 -04:00
Brandon ThomasandClaude Opus 5.5 ec39ca1ad6 LR-EDIT-LIGHT-HIGHLIGHTS: merge main; binned previews' clip mask on top of #607's reconstruction (#548)
Main's reconstruction (#607) replaces this branch's rewrite of highlight::reconstruct. Kept from the
branch: develop_binned_masked (clipping decided per sample before averaging) and the denoised twin
using the same mask. New: highlight::reconstruct_masked, #607's reconstruction with the clipped
channels told by the mask (block means as lower bounds), shared with reconstruct_with through a
generic clip test (no slowdown on full-size images), and CameraColourModel::rebuild_highlights_masked
so binned previews keep the colour model's clipped white. RENDER_CACHE_VERSION 38.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:37:51 -04:00
Mark ParienteandClaude Opus 5.5 f9bbeb9d00 LRC-LIB-CATALOG-IMPORT: find moved root folders relative to the catalog
A catalog copied from another computer (e.g. Windows to Linux) records
root folders at paths that don't exist here, so every original was
imported as missing. When a root's absolutePath isn't a folder, use the
relativePathFromCatalog Lightroom stores for roots on the catalog's
volume, resolved against the catalog's folder, and report the remapping
as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:35:25 -07:00
Andrey VyrodovandClaude Opus 5.5 cad9782ca1 Issue #345: long-running tasks use the activity stack (rule and guide)
CLAUDE.md gains the rule (a TaskGuard for every job that runs off the UI
thread and can take more than a moment; no progress window, panel or
toast of its own), and docs/background-tasks.md says how: kind, label,
progress, units, detail, the three ways to cancel, examples and tests.
Photo Merge's and the face scan's docs no longer describe the old
progress toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:31:32 +02:00
Brandon ThomasandClaude Opus 5.5 33f971c3f9 M11.4: restore main's contributors.json (regenerated by a local build by mistake)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:28:15 -04:00
Brandon ThomasandClaude Opus 5.5 9f73f086c9 M11.4: merge main; spectral matrices only replace the neutral fallback
Merge origin/main into #560 (fit_preview / camera-look refactors of #528, #583, #568, #616).
`starting_colour` now tries the per-file camera-JPEG fit (with the camera profile when there is
one) first and uses the spectral-sensitivity matrices only when that fit fails, before the neutral
fallback, so no photo with an accepted JPEG fit changes. Tests pin the order through an injectable
fit; docs, parity, ROADMAP and what's new describe it. RENDER_CACHE_VERSION 37 -> 38.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:27:47 -04:00
Andrey VyrodovandClaude Opus 5.5 56dfc8d027 Issue #345: Synchronize Folder and the contact sheet in the activity stack
Synchronize Folder (#604) shows its scan ("Looking for changes", while
its dialog is open) and its run ("Synchronizing folder") as rows: the
scan's cross stops it and closes the dialog, the run's stops it like
Cancel did (what was done stays, as one undo step; the end says Stopped).
Its progress window is gone. The contact-sheet PDF export (#563), which
went through the export task already, gets a test for its row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:17:45 +02:00
Brandon ThomasandClaude Opus 5.5 7a65cc6340 LR-CLOUD-SMARTPREVIEW: look refresh only for per-file-look raws, once per LOOK_VERSION, serialised with builds
Only proxies of raws that take the per-file camera look (Photo::relative_wb) go stale: JPEG/PNG/DNG proxies are never rebuilt for the look. The opening scan runs once per LOOK_VERSION (a look-version marker in the smart previews folder), not at every library open; Build Smart Previews still refreshes stale proxies. Each proxy is checked and written under one lock, so the background refresh and library.smartPreviews never write the same file at once. LOOK_VERSION 2: main's DRO / ILCE-7CR camera-look changes (#528, #583, #568, #616).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:16:54 -04:00
Brandon ThomasandClaude Opus 5.5 b7e791a136 Merge origin/main into smart-preview-look-version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:16:38 -04:00
Brandon Thomas 4539014a42 Merge PR #498
# Conflicts:
#	crates/raw/src/vendor/arw.rs
2026-10-10 03:16:18 -04:00
Brandon Thomas 7f1113c285 Merge PR #573 2026-10-10 03:16:16 -04:00
Brandon ThomasandClaude Opus 5.5 7962c78bc5 LR-EDIT-OPTICS-PROFILE: merge main; keep main's Sony crop precedence
Main (#575, #580) rewrote default_crop. The image crop now keeps main's
order for every Sony body (Sony's crop tags, then DefaultCrop, then the
recorded-size crop), so framing does not change. Only the ILCE-7RM4A
distortion warp takes its centre and normalization from DefaultCrop when
the file has one, which keeps the geometry validated against Sony's
exports (centre x 4783.5 on the A7R IVA sample). The DNG round-trip test
now checks both: Sony's crop on the image, the warp centred on the
default crop. RENDER_CACHE_VERSION 38.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:10:35 -04:00
Brandon Thomas 5240bdaa6f Merge remote-tracking branch 'origin/main' into HEAD 2026-10-10 03:02:17 -04:00
MarekSpodBiedry 01fc7a5e75 UI: star rating previews the hovered rating, Info tab and bottom bar (#647) 2026-10-10 09:01:03 +02:00
Brandon ThomasandClaude Opus 5.5 20d7d9e981 LR-IMP-FORMATS: merge main into the DSLR-A900 packed 12-bit ARW decode
Main's SR2SubIFD white balance (#575) already reads the A900's applied
levels (2688 / 1024 / 1024 / 1516, the same 2.625 / 1.480 gains), so the
hard-coded maker-note 0x0020 block at offset 188 is dropped. The centred
crop now goes through #580's recorded_size_crop (a `centred` flag, only
for the packed 12-bit layout); every other body keeps main's framing.
Adds the CC0 A900 sample to the corpus with checks for its white balance,
black level, bit depth and crop. RENDER_CACHE_VERSION 38.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:53:10 -04:00
Andrey VyrodovandClaude Opus 5.5 8c3c586b80 Issue #345: review fixes for the activity stack
A cross clicked in the frame its task ended no longer puts "no task N"
in the status bar: the row's cancel goes to the engine directly instead
of through the app's command runner, which reports every error there.

The face-scan row test scans demo photos no other scan test uses, so it
no longer joins the scan deadlock of #630 (with one of the existing pump
tests it hung 13 of 20 runs; now 0 of 20).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:56 +02:00
Andrey VyrodovandClaude Opus 5.5 e974a87cfb Issue #345: docs and parity for the activity stack
LR-BEHAV-BG-TASKS now cites activity.list / activity.cancel and lists
every job on the stack; the import and Build Previews rows no longer
describe progress windows and toasts; a what's-new entry.

Fixes the stack not growing when tasks arrive while it already shows a
row (an egui area lays out in last frame's size, and the scroll area took
that as all the room, so later rows were cut off), and adds the Russian
"Quit Anyway".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:54 +02:00
Andrey VyrodovandClaude Opus 5.5 1c89caa4a1 Issue #345: Find Missing Photos in the activity stack
Find Missing Photos shows a row (a sweep: the amount of work isn't known,
no cross) while it searches; the auto-import folder listing, which runs
every few seconds, stays quiet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:24 +02:00
Andrey VyrodovandClaude Opus 5.5 753ca97a4d Issue #345: AI Denoise in the activity stack
While a photo's AI Denoise picture is being made or waits its turn, the
stack shows "AI Denoise" with the running photo's tiles as a percentage
and its file name (no cross: what is made follows the Denoise amounts and
Settings). The row goes when the queue is idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:20 +02:00
Andrey VyrodovandClaude Opus 5.5 3f3c204987 Issue #345: face scan in the activity stack
While photos are left to search for faces the stack shows "Finding
faces" with how many are done (no cross: the scan is switched off in
Settings). The engine keeps the scan's peak, so Settings and the stack
agree. Also translates the "Downloading model" label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:17 +02:00
Andrey VyrodovandClaude Opus 5.5 de247ad54f Issue #345: model downloads in the activity stack
Downloading the SAM 3, face and AI Denoise models shows a row with the
model's name and the megabytes so far; its cross stops the download like
the Settings button. The denoise download tests get their own scratch
folders (they shared the face tests' names and could delete each
other's files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:14 +02:00
Andrey VyrodovandClaude Opus 5.5 e9e8b5a933 Issue #345: photo merge in the activity stack
The final HDR / panorama merge shows a row with its percentage and stage
instead of the progress toast, and stops from its cross ("HDR merge
cancelled"). The activity tests now let the first renders finish before
they run, and the half-second check tolerates a slow frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:50:04 +02:00
Andrey VyrodovandClaude Opus 5.5 c3bb8e9916 Issue #345: Lightroom catalog import in the activity stack
Inspecting and importing a Lightroom catalog show a row instead of the
progress window; reading the catalog stops from its cross, adding the
photos and saving the index run to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:49:52 +02:00
Andrey VyrodovandClaude Opus 5.5 9c8a721389 Issue #345: preview builds in the activity stack
Build Previews, Build Smart Previews and Discard Smart Previews show a row
with the photo count and stop from its cross; the start toast goes, the
finish toast stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:49:38 +02:00
Andrey VyrodovandClaude Opus 5.5 1af0dd4fe3 Issue #345: folder scan in the activity stack
The import review's folder scan (and reading a folder for Local) is a
row in the activity stack: "Looking for photos…" while the folders are
listed, then the files read of the total. Its cross drops the scan at
once, without waiting for the file being read, as the old Cancel did.
The separate Scanning window is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:49:34 +02:00
Andrey VyrodovandClaude Opus 5.5 63d9b70356 Issue #345: activity stack polish
The cross sits at the right edge of its row (the name's area kept only
the text's width, so the cross followed short names). Seen in the
running app; a test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:49:18 +02:00
Andrey VyrodovandClaude Opus 5.5 10e097abf6 Issue #345: import progress in the activity stack
An import (and Auto Import, and reading a folder for Local) registers in
the activity stack with the files done of the total; its cross and
activity.cancel set the import's cancel flag, so nothing new starts and
the result still reads "Import cancelled · N added" as one undo step.
The separate Importing window is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:49:15 +02:00
Andrey VyrodovandClaude Opus 5.5 dbf570c711 Issue #345: export progress in the activity stack
A background export registers in the activity stack with the photos done
of the total and the file in progress; its cross and activity.cancel set
the export's own cancel flag. The separate export panel at the bottom
left is gone; the result toast is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:48:58 +02:00
Andrey VyrodovandClaude Opus 5.5 98f65ff0d7 Issue #345: ask before quitting while tasks run
Quitting while an import, an export or another task that can be
cancelled is running asks first: Quit Anyway cancels them (then any
unsaved changes still get their own question), Cancel keeps working.
Tasks without a cross (the face scan, AI Denoise) pick up again on the
next launch and don't ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:47:58 +02:00
Andrey VyrodovandClaude Opus 5.5 af9b1063ca Issue #345: activity stack in the top-left corner
Every task in the engine's activity registry floats as a row under the
top bar once it is half a second old: its name, a progress bar (a sweep
while the amount of work isn't known), the count and what it is working
on, and a cross for the ones that can be cancelled. Three rows, then
"+N more". ui.inspect reports the same list as activity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:47:15 +02:00
Andrey VyrodovandClaude Opus 5.5 4e323ad26f Issue #345: activity.list and activity.cancel commands
The registry is reachable as commands, so the UI, the control channel,
the CLI and MCP list and cancel background tasks the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:45:25 +02:00
Andrey VyrodovandClaude Opus 5.5 533a382619 Issue #345: engine task registry (activity)
A registry of the background tasks in flight on Session: a job holds a
TaskGuard while it runs (dropping it, also during a panic unwind, removes
the row), adopts its existing cancel flag, and reports done/total in a
unit the frontend words. Groundwork for one progress stack for imports,
exports, preview builds and the other long jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:44:28 +02:00
Brandon ThomasandClaude Opus 5.5 5436951299 Contributors: credit GPT-6 Luna and opencode
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:23:24 -04:00
Brandon Thomas c545cc5e84 Merge pull request #659 from storytold/claude/integrate-batch-24
Integrate #607 follow-up, #655, #656
2026-10-10 02:18:46 -04:00
Brandon Thomas 4045eaed96 Merge PR #656
# Conflicts:
#	crates/gpu/src/lib.rs
#	docs/parity.md
2026-10-10 02:02:20 -04:00
Brandon Thomas b31e0e2ec6 Merge PR #655 2026-10-10 02:02:20 -04:00
Brandon Thomas 17af5ba949 Merge PR #607 (its follow-up commit; content applied above) 2026-10-10 02:02:03 -04:00
Alex ChambersandClaude Opus 5.5 aa6dfe3b30 LR-EDIT-LIGHT-HIGHLIGHTS: pale partly clipped skies turn white, not teal (#523)
Testing the first version on ILCE-7RM4 photos found a partly clipped sky between
bare branches leaning cyan to teal under #523's edit, where Lightroom shows white.
Where only green clipped, red and blue say such a sky is a pale cyan-blue; the
rebuild kept that colour until halfway to full clipping, and darkening and a camera
profile showed it. Colour borrowed from another surface (bright foliage below the
sky) could also push green up, with a small step where green first clips.

- highlight::reconstruct_with: the colour of a pale partly clipped pixel
  (saturation relative to the colour model's white below 0.2, easing off by 0.4)
  fades to the clipped neutral from the first clip on instead of from halfway;
  brightness and strongly coloured pixels keep the old schedule.
- Borrowed colour counts less and less as both the pixel's own channels and the
  rim's colour contradict it (0.15 to 0.4 stop); either alone can be wrong.
- Right past the rim every estimate continues the rim's colour (it used to hold
  the clipped channels at their clip, for the neutral estimate only), so the first
  clip has no step whatever lent the estimate. The rim is built whenever there is
  clipping.
- Nothing jumps where a later channel clips: in the rim distance and the
  contradiction, a clipped channel counts as the lower bound it is, and a channel
  goes over to that from 0.75 of its clip; a channel about to clip is a floor
  capped like the clip floor it becomes.
- Tests: a pale sky above yellow-green foliage with thin dark branches in it (no
  step where green clips, no green from the foliage, the same beside the branches,
  neutral well past the clip); the fringe test compares the sky beside the branch
  with the sky away from it, on its way to neutral; a band whose red clips second
  next to a rim of another colour renders without a step, in six layouts.
- docs/parity.md: the Highlights note. Render cache version 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:02:00 -04:00
Brandon Thomas 9e59e3dcea Merge pull request #657 from storytold/claude/integrate-batch-23
Integrate 59 community PRs (batches 21–23)
2026-10-10 02:00:34 -04:00
Brandon ThomasandClaude Opus 5.5 77421aeeda Web: no display-profile picker in the browser (#612 added the service)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:56:02 -04:00
hxwandClaude Opus 5.5 5a537d5d39 LR-BEHAV-GPU: no render is inside the GPU driver when the app quits (issue #620)
A render still running on a worker thread while the process exits crashes in the
driver (SIGSEGV in the NVIDIA Vulkan driver on Linux), where neither the panic
hook nor catch_unwind helps: pool workers were detached, the devices are statics
that are never dropped, and nothing on the way out waited for GPU work.

- gpu: every entry into a device (render, device creation, pool trim, denoise
  runner and tiles) is counted as work in flight (exit.rs). begin_shutdown()
  lets nothing new in; wait_idle(timeout) waits for what is inside.
- preview: JobPool keeps its workers. shutdown(timeout) and Drop discard the
  queued jobs and wait, bounded, for the running ones (2 s on drop); never for
  the calling thread, and not twice.
- engine: a render job caught by the shutdown gives up instead of rendering on
  the CPU, so quitting doesn't wait for it.
- app: on_exit ends with LightcraftApp::shutdown (2 s for everything), after
  settings and library are saved; the macOS menu-bar Quit goes through on_exit.

Not changed: the export, merge, denoise and import threads are still detached;
their GPU work goes through the gate, their CPU work is cut off at exit as before.

Tests: pool drop waits / is bounded / survives a panicked job and its own job
dropping it; the gate; GPU renders refused and the device not created after
begin_shutdown; the app's shutdown leaves no render worker. The crash itself
needs Linux + NVIDIA and was not reproduced here (Windows, DX12): 9 of 9 quits
of the app mid-render exit with code 0 in under 0.8 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:24 -05: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 <noreply@anthropic.com>
2026-10-10 11:15:17 +05:30
Alex ChambersandClaude Opus 5.5 41a61b74fc LR-EDIT-LIGHT-HIGHLIGHTS: pale partly clipped skies turn white, not teal (#523)
Testing the first version on ILCE-7RM4 photos found a partly clipped sky between
bare branches leaning cyan to teal under #523's edit, where Lightroom shows white.
Where only green clipped, red and blue say such a sky is a pale cyan-blue; the
rebuild kept that colour until halfway to full clipping, and darkening and a camera
profile showed it. Colour borrowed from another surface (bright foliage below the
sky) could also push green up, with a small step where green first clips.

- highlight::reconstruct_with: the colour of a pale partly clipped pixel
  (saturation relative to the colour model's white below 0.2, easing off by 0.4)
  fades to the clipped neutral from the first clip on instead of from halfway;
  brightness and strongly coloured pixels keep the old schedule.
- Borrowed colour counts less and less as both the pixel's own channels and the
  rim's colour contradict it (0.15 to 0.4 stop); either alone can be wrong.
- Right past the rim every estimate continues the rim's colour (it used to hold
  the clipped channels at their clip, for the neutral estimate only), so the first
  clip has no step whatever lent the estimate. The rim is built whenever there is
  clipping.
- Nothing jumps where a later channel clips: in the rim distance and the
  contradiction, a clipped channel counts as the lower bound it is, and a channel
  goes over to that from 0.75 of its clip; a channel about to clip is a floor
  capped like the clip floor it becomes.
- Tests: a pale sky above yellow-green foliage with thin dark branches in it (no
  step where green clips, no green from the foliage, the same beside the branches,
  neutral well past the clip); the fringe test compares the sky beside the branch
  with the sky away from it, on its way to neutral; a band whose red clips second
  next to a rim of another colour renders without a step, in six layouts.
- docs/parity.md: the Highlights note. Render cache version 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:40:18 +02:00
Brandon ThomasandClaude Opus 5.5 c118f960b1 LR-IMP-FORMATS: render cache version 36 (#609 decodes HEIC colour like libheif)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:26:31 -04:00
Brandon ThomasandClaude Opus 5.5 e46e7ab265 Merge PR #646 (HDR peak threaded through #533's base_tone; V1 at peak 1 unchanged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:26:27 -04:00
Brandon ThomasandClaude Opus 5.5 23af0a9d1c LR-BEHAV-LOCALIZE: French and Ukrainian for this round's new strings (smart-album rules, keyword list and sets, Synchronize Folder, ratings, crop angle)
The two new catalogs landed alongside PRs that added ~127 strings; the per-language
tests require them in every catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:25:38 -04:00
hxwandClaude Opus 5.5 3cdf18ba6d LR-VIEW-ZOOM: zoom windows cover a canvas wider than 2048 px (issue #652)
The loupe read the GPU's texture limit from the frame's raw input
(`i.raw.max_texture_side`). A native host (egui-winit) reports it in the
first frame's input only, so from the second frame on the loupe fell back
to 2048: every window render of a zoomed view was cut to 2048 x 2048 px,
and on a canvas wider than that (HiDPI, 4K, a large window) the strips
beside it showed the magnified whole-frame render. The whole-frame render
and the second-window and compare renders were capped the same way.

Read the limit egui keeps (`InputState::max_texture_side`) instead.

The headless host reported the limit in every frame, which is why no test
saw this: it now reports it once (and again when it changes), as a window
does. Two regression tests: the limit outlives the frame it was reported
in, and at 1:1 on a 2600 px canvas the window covers the canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:18:54 -05:00
Brandon ThomasandClaude Opus 5.5 88de143dd1 HDR: default HDR settings aren't serialized (hash64 and cached previews unchanged); NaN-safe peak; checked AVIF size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:15:51 -04:00
Brandon Thomas 259ddcc1a8 Merge PR #580 (its updated head; content already applied above) 2026-10-10 01:15:02 -04:00
Brandon ThomasandClaude Opus 5.5 3539de95ca Merge PR #628 (Auto renders under the photo's process, as #533 does)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:14:47 -04:00
Brandon Thomas 57f29bf8e1 Merge PR #636 2026-10-10 01:14:26 -04:00
Brandon Thomas d267a7acd5 Merge PR #640
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-10 01:14:26 -04:00
Brandon Thomas 37e95dd2fa Merge PR #651
# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/lib.rs
#	crates/raw/src/vendor/orf.rs
#	docs/parity.md
2026-10-10 01:14:25 -04:00
Brandon Thomas f35663590f Merge PR #633
# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/vendor/nef.rs
#	docs/parity.md
2026-10-10 01:14:24 -04:00
Brandon Thomas 78550e5de9 Merge PR #609
# Conflicts:
#	crates/engine/src/tests_import.rs
#	docs/parity.md
2026-10-10 01:14:24 -04:00
Brandon ThomasandClaude Opus 5.5 e7da679cb3 LR-IMP-FORMATS: word16_order reads sample pairs with as_chunks instead of indexing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:10:36 -04:00
Brandon ThomasandClaude Opus 5.5 0982cae7d5 OmaStore: exec paths match the tarball layout (bin/, no usr/)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:08:30 -04:00
Brandon ThomasandClaude Opus 5.5 82566817e6 LR-IMP-FORMATS: drop references to analysis files that aren't in the repo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:08:10 -04:00
Brandon Thomas 4bf125d116 Merge PR #623 (updated head) 2026-10-10 01:02:10 -04:00
Brandon Thomas b6f6a979e5 Merge PR #608 (updated head) 2026-10-10 01:02:10 -04:00
Brandon ThomasandClaude Opus 5.5 4a3f9e9b6d Apply #580's updated head (drops the DSC-R1 SR2 refusal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:02:09 -04:00
Brandon Thomas 2ecb495d18 Merge remote-tracking branch 'origin/main' into claude/integrate-batch-22 2026-10-10 01:01:03 -04:00
SawizzleandClaude Opus 5.5 4e39a51b24 docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:50:51 -07:00
akvaithiandClaude Opus 5.5 7828f93c32 LR-HDR-EXPORT: HDR AVIF (10-bit Rec. 2020 PQ)
`encode_avif_pq`: linear Rec. 2020 with SDR white at 1.0 → PQ (SDR white at
203 cd/m², BT.2408), BT.2020 NCL YCbCr 4:4:4, 10-bit, with CICP nclx
(9/16/9, full range) and a clli box. ravif fixes the sRGB curve, so this
drives the rav1e + avif-serialize it already uses (no new crates).

Export: AVIF with `hdr` renders the HDR edit in Rec. 2020 and writes PQ.
Photos without an HDR edit export as before, in every format. CoreImage
decodes the file as HDR (same 3.1 % of the frame above SDR white as the gain
map JPEG of the same edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:37 -05:00
akvaithiandClaude Opus 5.5 af06f2dbc1 LR-HDR-VISUALIZE: HDR controls in the Light panel, Visualize HDR, export option
- Light panel: HDR switch, headroom limit and an SDR Rendition flyout
  (shown with HDR on); Visualize HDR beside the switch.
- `Overlay::HdrRange` / `view.visualizeHdr`: the SDR render dimmed to grey,
  with pixels the HDR rendition puts above SDR white painted in four bands by
  stops above it. Rendered on the CPU from the float HDR render.
- Export dialog: "HDR output" for JPEG (gain map) and 32-bit TIFF.
- Parity (Q. HDR 2 ✅ 3 🟡, LR-EDIT-HDR-MODE ✅, LR-EXP-HDR 🟡), README, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:37 -05:00
akvaithiandClaude Opus 5.5 34d9c8f2d2 LR-EXP-HDR: HDR export (gain map JPEG, float TIFF) and develop.hdr
- `ExportOptions.hdr` (`hdr` param): photos edited in HDR export as ISO
  21496-1 gain map JPEGs (the SDR rendition as the base, the gain map measured
  against it as written) or 32-bit float TIFF with highlights above 1.
  Other formats and SDR edits export as before.
- Output sharpening and watermarks apply to both renditions.
- `develop.hdr {enabled?, maxEv?}` turns HDR editing on/off and sets the
  headroom limit.

Checked on macOS: ImageIO reports both the ISO and the Apple gain map, and
CoreImage's HDR decode of a 3 EV edit reaches 2.95 EV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:37 -05:00
Alex ChambersandClaude Opus 5.5 b4ab88b751 LR-EDIT-LIGHT-HIGHLIGHTS: clipped highlights stay neutral when darkened (#523)
Sensor-clipped pixels are rebuilt at raw decode. The tone map rolls them off to
white at Neutral, so any colour they carry shows only when Highlights, a negative
Exposure or Whites darkens them. They took colour from the nearest unclipped pixel
(dark foliage, lens fringes along branches, pixels demosaiced from clipped samples),
left two clipped channels at their white-balance-scaled clip levels (red or pink),
and were neutral only in camera RGB, not after the colour model (a look fitted to
the camera's JPEG, a camera profile).

- highlight::reconstruct_with: chromaticity only from bright unclipped content away
  from edges and from the clip, through a normalised push-pull pyramid; a neutral
  estimate blended in where reliable content is scarce at every scale up to a
  quarter of the long edge, applied only as far as the pixel lies past the nearest
  near-clip unclipped pixels (so a surface still runs into clipping without a
  step); clipped pairs keep the estimate's ratio at their clip floors; partly
  clipped pixels fade into the clipped neutral on their way from the first channel
  clipping to the last; fully clipped pixels become the colour model's neutral.
  Pixels without a clipped channel are left exactly as they were.
- engine: the decode colour step as CameraColourModel, with clipped_white, which
  keeps the camera's neutral for nearly singular or implausible models.
- Tests in raw (continuity along a ramp through every clip, also as lines 1 to 16
  rows thick with no reliable colour, on canvases 32 to 1024 rows), engine
  (pipeline renders at Highlights -100, #523's edit and Exposure -2 through the
  first clip, over the same sweep; a CC0 ILCE-7RM4 corpus file added to
  RAW_SAMPLES) and the GPU equivalence suite.
- Render cache version 27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:22:22 +02:00
Alex ChambersandClaude Opus 5.5 ed49c73ac8 LR-IMP-CAMERA-COVERAGE: Sony 16:9 crop on pre-crop-tag bodies
Part of #535 (item 4).

Pre-2017 bodies without Sony crop tags shot in the camera's 16:9 mode now get
the 16:9 crop of their camera JPEG: width from FullImageSize, anchored left as
before (drops the ILCE-7SM2's 32 padding columns); height from FullImageSize
(ILCE-7SM2) or from an Exif image size of the same width (DSLR-A580, whose
FullImageSize still says 3:2); centred vertically at an even offset, as measured
by registering each camera JPEG on its raw. The aspect now matches the JPEG, so
the camera-look fit runs: ILCE-7SM2 dE 15.2 -> 4.8, DSLR-A580 dE 26.7 -> 3.9.
Only these 2 of 140 Sony decodes change; render cache version 26 (23-25 are
taken by #568, #542, #575).

Tests: crop helper (3:2 unchanged, both 16:9 sources, even offsets, hostile
sizes) and a corpus test of the two crops against their camera JPEGs; both
samples added to the corpus list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:18:58 +02:00
akvaithiandClaude Opus 5.5 e11db8fea0 LR-HDR-EXPORT: ISO 21496-1 gain map JPEG codec
`lightcraft_codecs::gainmap`:
- `compute` measures a gain map (luminance or RGB, log2, block-averaged)
  from an SDR and an HDR rendition; `apply` rebuilds the HDR rendition.
- ISO 21496-1 metadata: writer (fixed denominator) and reader (also the
  common-denominator form; refuses unknown versions, backward maps and zero
  denominators). Adobe `hdrgm` XMP reader and writer, per-channel values as
  rdf:Seq; Apple's HDRGainMap/HDRToneMap description of the same map.
- `encode_jpeg`: base with CIPA DC-007 MPF index (gain map typed 0x050000),
  the ISO marker and Container XMP merged into the user's packet, followed by
  the gain map JPEG. `read_jpeg` / `is_gain_map_jpeg` find and decode the map.

Also: gain map images are no longer picked as embedded thumbnails (an Ultra
HDR JPEG could show its greyscale gain map in the grid). The JPEG APP-segment
building is shared with `encode_jpeg`. Fuzzed with the other decoders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:01:35 -05:00
akvaithiandClaude Opus 5.5 791526aa8c LR-HDR-EDIT: HDR settings, HDR tone map and the float HDR render
- `DevelopSettings.hdr`: enabled, headroom limit (`max_ev`) and SDR-rendition
  offsets (brightness, contrast, highlights, shadows, whites, clarity), with
  control specs (`hdr.maxEv`, `hdr.sdr*`) and copied with the Light group.
- `ToneMap::hdr` / `display_hdr`: the SDR curve through the midtones, with
  highlights lifted up to `2^max_ev`; identical to the SDR tables at peak 1.
- `OutputDepth::F32Hdr`: unclamped linear output up to the peak. The finish
  stage carries the peak through highlight desaturation, gamut mapping,
  encoding, curves, grain and LUT profiles; with HDR off every branch is the
  SDR code path, bit for bit.
- `settings_for`: every non-HDR render (CPU and GPU) uses the SDR rendition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:00:54 -05:00
radialmonster d138236291 LR-IMP-FORMATS: Sony DSC-R1 .SR2 decodes (big-endian 16-bit words in a little-endian TIFF)
Uncompressed 16-bit words are read in the other byte order only when the
file's order leaves more than 1% of sampled words at or above
1 << max(bits, 14) and the other order leaves none. Measured on the CC0
DSC-R1 (raw.pixls.us 3221). Crop and white balance (SR2SubIFD) are not
addressed. RENDER_CACHE_VERSION 31 (adjust at merge).
2026-10-09 22:01:36 -04:00
KitsuneSemCalda 7059f4a565 Add OmaStore manifest 2026-10-09 22:50:45 -03:00
sanyamsingh001andCodex 41e95fa78e LR-IMP-ADD-DIALOG: settle Copy layout before locating Tags
Preserve the existing tag assertions and deadlines after editing the rename template.

Co-authored-by: Codex <codex@openai.com>
2026-10-10 11:46:07 +10:00
radialmonster 44b126274d LR-IMP-FORMATS: decode 12.8 bpp block-packed ORF (E-300, E-500, E-330, high-res E-M5 II / PEN-F)
Single-strip ORFs at 12.8 bits per pixel are 16-byte blocks: ten little-endian
12-bit samples in bytes 0..14 plus a zero pad byte. Main routed them through the
XZ-2 12-bit word path (stripes, magenta cast). New branch before it; the exactly
12.0 bpp XZ-2 branch is unchanged. Layout measured on CC0 files (log and scripts in
data/testing/retry-olympus-e300 and tools/retry-olympus-e300).

RENDER_CACHE_VERSION bumped to 30 (open PRs use up to 29; adjust at merge).
2026-10-09 21:42:15 -04:00
Brandon ThomasandClaude Opus 5.5 b4ed033c2d Integrate batch 22: fix cross-PR breaks
- #540's crs::Target replaces the raw flag: #597/#604's folder sync and sidecar_op and #533's
  process tests pass the photo's target instead of a bool.
- #568's DRO test fits through #528's fit_look (proxies now return the clipped mask).
- #541 (French) and #564 (Ukrainian) gain the 15 keyword format strings #578-#592 added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:04:48 -04:00
Brandon Thomas 987a572512 Merge PR #612
# Conflicts:
#	crates/pipeline/src/finish.rs
#	docs/parity.md
#	docs/whats-new.md
2026-10-09 20:56:58 -04:00
Brandon Thomas 89753f5d67 Merge PR #563
# Conflicts:
#	docs/parity.md
2026-10-09 20:56:57 -04:00
Brandon Thomas 53c056daf3 Merge PR #564
# Conflicts:
#	README.md
#	crates/ui-egui/src/i18n.rs
#	docs/localization.md
#	docs/parity.md
2026-10-09 20:56:56 -04:00
Brandon Thomas 0fc81747cb Merge PR #541 2026-10-09 20:56:55 -04:00
Brandon Thomas 2f35949678 Merge PR #604
# Conflicts:
#	crates/engine/src/sidecar.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/panels/dialogs.rs
#	docs/whats-new.md
2026-10-09 20:56:55 -04:00
Brandon Thomas b785023073 Merge PR #597
# Conflicts:
#	crates/catalog/src/journal.rs
#	crates/catalog/src/lib.rs
#	crates/catalog/src/local.rs
#	crates/catalog/src/tests_format_version.rs
#	crates/catalog/src/tests_journal.rs
#	crates/ui-egui/src/panels/left.rs
#	docs/whats-new.md
2026-10-09 20:56:33 -04:00
Brandon Thomas 2421f2223b Merge PR #592
# Conflicts:
#	crates/ui-egui/src/tests_panels.rs
#	docs/whats-new.md
2026-10-09 20:55:50 -04:00
Brandon Thomas bf0656fa95 Merge PR #588
# Conflicts:
#	docs/whats-new.md
2026-10-09 20:55:49 -04:00
Brandon Thomas 4372750986 Merge PR #585
# Conflicts:
#	docs/whats-new.md
2026-10-09 20:55:49 -04:00
Brandon Thomas d0f042c269 Merge PR #578
# Conflicts:
#	crates/catalog/src/journal.rs
#	crates/catalog/src/tests_format_version.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/whats-new.md
2026-10-09 20:55:40 -04:00
Brandon Thomas c2142540fe Merge PR #561
# Conflicts:
#	docs/parity.md
#	docs/whats-new.md
2026-10-09 20:54:56 -04:00
Brandon Thomas 244d279091 Merge PR #536
# Conflicts:
#	crates/catalog/src/tests.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/lib.rs
#	docs/parity.md
2026-10-09 20:54:53 -04:00
Brandon Thomas a891c62eb9 Merge PR #550
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
2026-10-09 20:54:47 -04:00
Brandon Thomas 771882ae52 Merge PR #570 2026-10-09 20:53:53 -04:00
Brandon Thomas 8b90ca75a5 Merge PR #621 2026-10-09 20:53:53 -04:00
Brandon Thomas d7bc58a174 Merge PR #623
# Conflicts:
#	docs/mcp.md
2026-10-09 20:53:53 -04:00
Brandon Thomas 15850a55b2 Merge PR #580
# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/vendor/arw.rs
#	crates/raw/tests/corpus.rs
#	docs/parity.md
#	xtask/src/main.rs
2026-10-09 20:53:21 -04:00
Brandon Thomas 4a8241dde4 Merge PR #575
# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/tests/corpus.rs
#	docs/parity.md
#	xtask/src/main.rs
2026-10-09 20:51:50 -04:00
Brandon Thomas f4f7d5c401 Merge PR #616
# Conflicts:
#	crates/catalog/src/model.rs
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-09 20:51:49 -04:00
Brandon Thomas 2c5121ae3b Merge PR #568
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/media.rs
#	docs/camera-preview-colour.md
#	docs/parity.md
2026-10-09 20:51:08 -04:00
Brandon Thomas d9f63f0b7b Merge PR #583
# Conflicts:
#	crates/engine/src/media.rs
2026-10-09 20:51:08 -04:00
Brandon Thomas db03b4a586 Merge PR #528
# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/media.rs
2026-10-09 20:51:07 -04:00
Brandon Thomas 6f323e01d9 Merge PR #533
# Conflicts:
#	docs/parity.md
2026-10-09 20:50:41 -04:00
Brandon Thomas 6bf2722209 Merge PR #607
# Conflicts:
#	crates/engine/src/media.rs
#	xtask/src/main.rs
2026-10-09 20:50:23 -04:00
Brandon Thomas 41cdec5daf Merge PR #586
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-09 20:50:08 -04:00
Brandon Thomas 630e2edc47 Merge PR #522 2026-10-09 20:50:06 -04:00
Brandon ThomasandClaude Opus 5.5 835dcf6657 Keep the UI-zoom tests: set the interface scale with ui.zoomFactor now that Cmd+Plus zooms the photo (#566)
The CLI snapshot test and the headless resize test sent Cmd+Plus to change egui's
interface scale; with the keys handed to View > Zoom they no longer did. A
ui.zoomFactor control request (0.5-3, documented) sets the scale explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:49:00 -04:00
Brandon ThomasandClaude Opus 5.5 ddfb617ff4 ci: keep the craft-fonts checkout comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:47:52 -04:00
Brandon ThomasandClaude Opus 5.5 54b72b4566 docs: drop the third-party plugin link (keep the install paths)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:47:16 -04:00
Brandon ThomasandClaude Opus 5.5 58853112e7 LR-PREF-DISPLAY: drop the duplicated not-yet note (the row already lists them as missing)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:47:09 -04:00
Brandon ThomasandClaude Opus 5.5 0ea4c50c2a LR-PREF-DISPLAY: 🟡 until the OS profile is detected and followed per monitor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:46:18 -04:00
Brandon ThomasandClaude Opus 5.5 93e48f8be7 LR-BEHAV-LOCALIZE: Ukrainian coverage gaps are reported, not failed; Compare keeps Lightroom's "Select" label
A label added by another PR (Decrease Rating, Display Profile, …) no longer fails the
Ukrainian test; it shows in English until translated, as for the other languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:44:04 -04:00
Alex ChambersandClaude Opus 5.5 8b9a020084 LR-EDIT-AUTO: Lightroom-like Auto, calibrated on Lightroom's Auto results (#318, #209)
Auto was a percentile rule that set Exposure about 1.4 EV higher than
Lightroom's Auto on the same photos (mean +1.81 against +0.44 EV) and
barely used Shadows: on 24 Sony raw photos its results were further from
Lightroom's Auto results (median dE76 22.0) than leaving the photo
unedited (7.9), about 18 L* too bright.

Auto is now Lightroom's shape of rule with values chosen for our results:
- Exposure = -0.58 - 0.41 * the scene's log-average luminance (EV from
  middle grey, scene-linear, under the photo's white balance, so it does
  not depend on the base tone curve), clamped to -2..3 EV. The slope is
  Lightroom's: its Auto's chosen Exposure against our log average, r -0.92;
- Highlights -66 and Shadows +52: Lightroom's own average Auto values;
- Contrast +68 and Vibrance +30 (our scales; our base tone is flatter);
  Whites, Blacks and Saturation 0.
The exposure offset, Contrast and Vibrance were fitted jointly so our
renders match Lightroom's Auto exports on the 16 photos whose starting
look Sony's DRO doesn't brighten (#568 changes the other 8). Held out one
photo at a time, each fold fitted from its own training photos only:
median dE76 6.38 on those 16 (8.28 unedited, 18.81 with the old rule),
12 of 16 closer than unedited; 7.70 on all 24 (7.92 unedited); on the 8
DRO photos 9.39 (7.00 unedited), their brighter start showing through.
Freeing Highlights and Shadows too (5 fitted values) did no better held
out (6.51) and left them poorly determined across folds.

The log average skips pixels that aren't finite (a damaged decode) through
the proxy's resampling and white balance; with no valid pixel, Exposure 0.

Values are black-box observations of Lightroom (the settings its Auto
chose and its exports), as in #514. Auto only applies when asked for, so
existing edits keep their look; HDR merges with Auto Settings use the
same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:43:11 +02:00
Brandon ThomasandClaude Opus 5.5 010d9bb5da LR-IMP-FORMATS: saturation-point search refuses NaN, negative or inverted levels and overflowing active areas (underflowed on a negative black level)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:43:04 -04:00
Brandon Thomas 4c4e9b154f Merge origin/main into #522 (keep both new masking tests) 2026-10-09 20:38:01 -04:00
Brandon Thomas dea37269ba Merge PR #520 2026-10-09 20:36:41 -04:00
Brandon Thomas 6fcc1e5e28 Merge PR #571 2026-10-09 20:36:41 -04:00
Brandon Thomas c5551783d2 Merge PR #527 2026-10-09 20:36:41 -04:00
Brandon Thomas 214f1a6af5 Merge PR #581
# Conflicts:
#	crates/engine/src/crs.rs
2026-10-09 20:36:40 -04:00
Brandon Thomas 3a06aab5bc Merge PR #584 2026-10-09 20:36:11 -04:00
Brandon Thomas 6168511f66 Merge PR #574 2026-10-09 20:36:11 -04:00
Brandon Thomas 8329b7be0a Merge PR #608 2026-10-09 20:36:11 -04:00
Brandon Thomas 21b58f4653 Merge PR #562 2026-10-09 20:36:11 -04:00
Brandon Thomas 9a694a4d41 Merge PR #572 2026-10-09 20:36:10 -04:00
Brandon Thomas 6203fa36a1 Merge PR #557 2026-10-09 20:36:10 -04:00
Brandon Thomas 514e828508 Merge PR #540 2026-10-09 20:36:10 -04:00
Brandon Thomas bde7ff911e Merge PR #530 2026-10-09 20:36:10 -04:00
Brandon Thomas 40e9c56119 Merge PR #509 2026-10-09 20:36:10 -04:00
Brandon Thomas e282505af2 Merge PR #505 2026-10-09 20:36:09 -04:00
Brandon Thomas 98b83a9a2a Merge PR #504 2026-10-09 20:36:09 -04:00
Brandon Thomas d23ff731ee Merge PR #502 2026-10-09 20:36:09 -04:00
Brandon Thomas 8a06fa0e5a Merge PR #500 2026-10-09 20:36:09 -04:00
Brandon Thomas e87863d48d Merge PR #549 2026-10-09 20:36:08 -04:00
Brandon Thomas 3726520504 Merge PR #526
# Conflicts:
#	ROADMAP.md
2026-10-09 20:36:08 -04:00
Brandon Thomas bd32d82a1f Merge PR #524
# Conflicts:
#	docs/parity.md
2026-10-09 20:36:07 -04:00
Brandon Thomas f41a6357eb Merge PR #600
# Conflicts:
#	crates/engine/src/media.rs
2026-10-09 20:36:07 -04:00
Brandon Thomas a1993c7093 Merge PR #615
# Conflicts:
#	docs/parity.md
2026-10-09 20:35:30 -04:00
Brandon Thomas edb3f29351 Merge PR #603
# Conflicts:
#	docs/parity.md
2026-10-09 20:35:29 -04:00
Brandon Thomas 414332a74d Merge PR #531 2026-10-09 20:34:54 -04:00
Brandon Thomas 1d3b69fe63 Merge PR #513 2026-10-09 20:34:54 -04:00
Brandon Thomas 89dc78b3bf Merge PR #508 2026-10-09 20:34:54 -04:00
Dylan M. Taylor 96ab5617b8 chore: bump github actions to fix Node 20 deprecation warnings 2026-10-09 20:08:30 -04:00
SawizzleandClaude Opus 5.5 2339b05eec docs: register the MCP server from an installed release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:06:24 -07:00
Daniel Spruce 06550937e8 LR-CROP-ASPECT: remember the last crop aspect as the default 2026-10-10 10:33:44 +11:00
Nathaniel LedfordandClaude Opus 5.5 58eec1f688 UI tests: click the Remove missing box again until the tick lands
Under a fully loaded test run a single queued click could arrive while
the dialog was still settling into place and miss the box. The helper
now clicks again at the box's current place until it is ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:05:22 -04:00
Exalted5373andClaude Opus 5.5 b5440f167f LR-IMP-CAMERA-COVERAGE: CR2 black level from the masked columns only
Canon's SensorInfo left border is not where light starts: on every corpus
body 10-36 columns left of it already hold image (EOS 6D: masked columns
0-70, border 84), and the black level was averaged over all of them. The
6D read 2591 instead of 2047 (4 % of the range), so CR2s rendered dark with
crushed shadows; 40D +141, 5DS R +131, 5D Mark II +73. The masked run is
now measured from the data: columns whose mean stays within 1/256 of the
range of the leftmost columns', minus a two-column margin.

Against Lightroom Classic (EOS 6D still life, 2000 px, ΔE00): base 7.63 ->
5.23 (p95 24.7 -> 11.3); mean over 86 edit variants abs 9.13 -> 6.19,
effect 3.09 -> 2.75.
RENDER_CACHE_VERSION 23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:04:31 -07:00
Exalted5373andClaude Opus 5.5 c21c305e55 LR-PROF-CAMERACOLOR: Olympus ORF starting look fitted to its own JPEG
The ORF decoder supplies the as-shot white balance (ImageProcessing
WB_RBLevels) and no colour matrix, like ARW, NEF, RW2, RAF, CR2/CR3, PEF
and SRW, but ORF was left out of camera_preview::file_local_look and the
catalog's relative_wb, so it rendered with the neutral fallback matrix:
flat, grey and dark. It now gets the same guarded fit to its camera JPEG
and white balance relative to the as-shot look. Corpus (held-out RMS
against the fallback, all accepted): E-1 0.126 -> 0.014, E-400 0.087 ->
0.022, XZ-2 0.061 -> 0.023; compressed ORFs still open from their preview.

Against Lightroom Classic (CC0 E-1 kit, 2000 px, ΔE00): unedited render
11.25 -> 8.30; mean over 86 variants abs 12.10 -> 9.48, effect 3.44 ->
2.80. Every other kit's unedited render is byte-identical.
RENDER_CACHE_VERSION 23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:04:18 -07:00
Nathaniel LedfordandClaude Opus 5.5 8233d1e412 Synchronize Folder: progress reaches its end; say what still writes on the UI thread (review fixes)
The relink check counted only the photos still to relink, so when some
moved files were gone again the bar stopped short of its total. The
module docs now name the writes that stay on the UI thread after each
step (the durable save; auto-written sidecars), and a test that only
showed the work checks files again is named for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:57:15 -04:00
Nathaniel LedfordandClaude Opus 5.5 47dfdd3c0e Engine: work out sidecar naming for many photos in one pass (review fix)
Session::sidecar_naming builds the catalog's sidecar owners (a pass over
every photo) for each photo it is asked about, so readying a synchronize
(on the UI thread) with M sidecar updates in an N-photo library cost
M·N — seconds for thousands of updates in a large library. The folder
scan had the same pattern. Session::sidecar_namings works them out once
for a list of photos; both use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:56:49 -04:00
Nathaniel LedfordandClaude Opus 5.5 f6bb274da3 UI: a Synchronize Folder and an import never run at once (review fix)
Each readies its files against the library as it was when it started,
so with the synchronize in the background, dropping the same folder on
the window (or Auto Import) while it ran could add the same files twice.
Imports (the review, dropped paths, Auto Import) wait while a folder is
being synchronized, and Synchronize waits for a running import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:56:04 -04:00
Nathaniel LedfordandClaude Opus 5.5 0f8b23875d UI: a Synchronize Folder that was stopped, failed or not saved says so (review fixes)
The end of a run threw away execute_fn's result, so a step that went
into the catalog but couldn't be saved (a full disk) went unmentioned,
and a run stopped with Cancel, or ended by a panic in its worker, with
nothing done toasted "The library is up to date with this folder."
The toast now leads with Stopped / Stopped by an error, adds Not saved
with the reason (both logged, and the toast stays longer), and only
says "up to date" when the run finished with nothing to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:54:39 -04:00
Nathaniel LedfordandClaude Opus 5.5 05178a783f Synchronize Folder: steps never land in another library or swallow an Undo (review fixes)
With the work in the background, File › Open Library during a run sent
its remaining steps into the newly opened catalog, where the same photo
ids are other photos: removals, relinks and sidecar reads hit unrelated
photos. SyncCommit now remembers the library it was readied for
(library_identity, as the Lightroom import job does) and drops steps for
any other, saying so in the report.

An Undo (or an edit) made between two of the run's steps was merged
into the "Synchronize Folder" step, so undoing it undid that earlier,
unrelated step too. The run's steps are merged only while they are
contiguous on the history; otherwise each stays its own step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:52:26 -04:00
Nathaniel LedfordandClaude Opus 5.5 f7828fdfbc UI tests: wait for the Remove missing tick to land before going on
A click is queued input: under a loaded test run it could land after the
stale confirm had replaced the dialog, on a box that was disabled while
scanning, so the choice the test checks was never made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:48:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 4153f62bb5 Docs: Synchronize Folder runs in the background
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:44:18 -04:00
Nathaniel LedfordandClaude Opus 5.5 d43efe713a UI: Synchronize Folder runs in the background, with progress and Cancel
Synchronize used to run folder.synchronize on the UI thread: the import
of the new files, the existence checks and the sidecar reads all made
the window wait. Confirming now only takes the dialog's scan and hands
its SyncWork to a worker thread; the dialog closes at once, a progress
window ("Synchronizing… n / m", Cancel) shows while it runs, the steps
are committed between frames through execute_fn (auto-write, durable
save, panic guard) and the whole run is one undo step, with a toast
saying what was done. Cancel stops before the next piece; what was done
stays. A stale scan is still refused and made again. Headless `busy`
now waits for the scan and the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:43:39 -04:00
sanyamsingh001andCodex f78b60b84a LR-IMP-ADD-DIALOG: initialize UI before measuring import frames
Co-authored-by: Codex <codex@openai.com>
2026-10-10 08:43:15 +10:00
Nathaniel LedfordandClaude Opus 5.5 90a38718e7 Engine: synchronize in halves, so no file is touched on the UI thread
SyncJob::start readies a synchronize from the session without any
file-system call; SyncWork::run does all of the file work (the import's
probes in batches, whether moved and missing files are still as the scan
found them, reading the sidecars) and hands over SyncSteps; SyncCommit
applies each to the catalog without touching the disk and finish()
makes them one undo step. The app runs the work on a worker thread;
folder.synchronize runs the same halves in turn. Progress (done/total)
and cancel go through SyncProgress, one task from start to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:39:33 -04:00
Alex ChambersandClaude Opus 5.5 9abf0d4210 LR-EDIT-LIGHT-HIGHLIGHTS: clipped highlights stay neutral when darkened (#523)
Sensor-clipped pixels are rebuilt at raw decode. The tone map rolls them off to
white at Neutral, so any colour they carry shows only when Highlights, a negative
Exposure or Whites darkens them. They took colour from the nearest unclipped pixel
(dark foliage, lens fringes along branches, pixels demosaiced from clipped samples),
left two clipped channels at their white-balance-scaled clip levels (red or pink),
and were neutral only in camera RGB, not after the colour model (a look fitted to
the camera's JPEG, a camera profile).

- highlight::reconstruct_with: chromaticity only from bright unclipped content away
  from edges and from the clip, through a normalised push-pull pyramid; a neutral
  estimate blended in where reliable content is scarce at every scale up to a
  quarter of the long edge, applied only as far as the pixel lies past the nearest
  near-clip unclipped pixels (so a surface still runs into clipping without a
  step); clipped pairs keep the estimate's ratio at their clip floors; partly
  clipped pixels fade into the clipped neutral on their way from the first channel
  clipping to the last; fully clipped pixels become the colour model's neutral.
  Pixels without a clipped channel are left exactly as they were.
- engine: the decode colour step as CameraColourModel, with clipped_white, which
  keeps the camera's neutral for nearly singular or implausible models.
- Tests in raw (continuity along a ramp through every clip, also as lines 1 to 16
  rows thick with no reliable colour, on canvases 32 to 1024 rows), engine
  (pipeline renders at Highlights -100, #523's edit and Exposure -2 through the
  first clip, over the same sweep; a CC0 ILCE-7RM4 corpus file added to
  RAW_SAMPLES) and the GPU equivalence suite.
- Render cache version 27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:39:09 +02:00
Nathaniel LedfordandClaude Opus 5.5 5a21fc9d97 Import: a file gone or changed since the review is probed again
The import reused the review's probes for every file it had seen, even
one deleted (or rewritten) since: such a file was added as a photo of a
file that isn't there. A cached probe is now used only while the file
is there with the same size (checked in prepare_files, on the import's
worker thread; the web's files keep their probes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:38:52 -04:00
Nathaniel LedfordandClaude Opus 5.5 f3a3fa6c10 Engine: Session::sidecar_op, the half of Read Metadata that reads no file
read_sidecar_op = read and parse the packet (file I/O) + sidecar_op
(build the op from what it says). The split lets a worker thread do the
reading while the UI thread only builds and commits the op. No change in
behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:36:27 -04:00
Nathaniel LedfordandClaude Opus 5.5 7478407d07 Docs: Synchronize Folder relinks Duplicates and photos without a hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:28:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 465c1f87fc UI: a Synchronize Folder rescan keeps what was ticked (review fix)
When a stale scan was made again, the dialog came back with the default
choices, so a ticked "Remove missing" or "Read metadata" was silently
dropped before the next click. The choices now carry over. A saved
dialog without `relink_moved` reads it as on, the engine's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:28:31 -04:00
Nathaniel LedfordandClaude Opus 5.5 97e2c7ff51 Synchronize Folder: background face search doesn't make a scan stale (review fix)
A scan was current only while the folder's photo records were the very
same; the face search adding regions in the background replaced them,
so confirming could keep failing as stale while it worked through the
folder. A photo now counts as changed only when something synchronizing
depends on changed: its file, its place in the library, or what a
sidecar is compared with (regions aside).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:28:27 -04:00
Nathaniel LedfordandClaude Opus 5.5 898ecee27c Synchronize Folder: relink renamed photos without a hash, and duplicates (review fixes)
Moved-file matching only knew photos with a content hash, one per hash:
a photo taken over from a Lightroom catalog (no hash on record) that was
renamed still showed as missing with its file as new, so Remove missing
+ Import new swapped it for a bare copy; a Photo › Duplicate (hash
"<h>:dup<id>") never matched; and of two missing files with the same
content only the first was found.

Hashes now match by the content they stand for, each missing file
claiming one file; a photo with no hash matches by file size and
dimensions, only when exactly one missing file and one unclaimed file
agree. A Local record listed as new no longer carries its
"duplicate"/"existing" marks in the scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:28:12 -04:00
Claude cf9d8f7ebb M11: name Nikon High Efficiency NEFs instead of blaming a missing table (#193)
High Efficiency NEFs (HE / HE★, Z 8, Z 9, Z 6III, Z f) keep compression
34713 but carry a JPEG XS-based codestream (SOC + CAP, FF10 FF50) and no
maker-note 0x0096 table. They fell through to "Nikon compressed NEF
without a linearization table", so the Preview only notice gave Z f users
no hint why their raw wasn't decoded or that Lossless compressed NEFs work.

Detect the codestream before asking for the table and report "Nikon High
Efficiency NEF" with the workaround. Add a synthetic regression test, CC0
Z f HE + Lossless samples to the corpus (raw.pixls.us) with a corpus test,
and list HE NEF among the format gaps in parity.md and ROADMAP.md.

No decoder: the format has no public specification and the existing
decoders (LibRaw, rawler) are LGPL and derived from Nikon's binaries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011bbmJTxYJyYqKfNH8uXRC6
2026-10-09 16:21:11 -06:00
Nathaniel LedfordandClaude Opus 5.5 f2bb46b7b7 Synchronize Folder: state the metadata-update limits as they are (review fix)
The docs claimed only a sidecar written here before auto-write was
turned off could be mistaken for an update; in fact the library only
knows when a photo came in or was last developed, so any later rating,
label or metadata change made here can be. Says so, with the headless
fixed clock, the sidecar naming fallback and the walk's limits; the
parity row and what's new also cover relinking moved files, Local
files, offline folders and the `disk` guard. Adds the test that a
sidecar the library auto-wrote is never an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:19:49 -04:00
Nathaniel LedfordandClaude Opus 5.5 82d46b14cd Synchronize Folder: progress counts the photo checks too (review fix)
The dialog's "Scanning… n / m" counted only the files probed, so it
could sit at m / m while the library's photos were still being checked.
SyncProgress counts both halves (its `files.cancel` still stops the
whole scan).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:18:33 -04:00
Nathaniel LedfordandClaude Opus 5.5 c98010cffa Synchronize Folder: act on the scan shown, never rescan on the UI thread (review fixes)
A scan went stale on any catalog change anywhere (a background import,
a rating elsewhere), and folder.synchronize then scanned again inline:
on the UI thread, and acting on results the person never saw. A scan
now stays current while the folder's own photos are the same records
(Session::take_folder_changes compares them by identity). The dialog
asks with `scanned: true`, which refuses a stale scan instead of
redoing it; the dialog then stays open and scans again in the
background. Agents that just call folder.synchronize still get a fresh
scan.

The app dropped session.folder_changes every frame no dialog was open,
so an agent's folder.scanChanges never reached its folder.synchronize
in the running app; it now only lets go of a scan its dialog made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:16:32 -04:00
radialmonster ac93118911 LR-EDIT-LIGHT-HIGHLIGHTS: partly clipped highlights take the colour of bright neighbours, binned previews clip before averaging (#548)
Highlight reconstruction rebuilds clipped channels from the chromaticity of
unclipped pixels nearby. Three changes to that colour:
- an unclipped pixel counts by its brightness ((m / 0.8 clip)^4, at most 1),
  filled coarse to fine by push-pull, so dark wires across a bright cloud no
  longer tint it (the iPhone frame of #548 turned cyan at Highlights -100);
- pixels within 6 px of clipping (partly demosaiced from clipped samples, 25 %
  greener than the sky on that frame) give no colour unless nothing else does;
- the rebuilt colour is drawn toward neutral at its brightest channel as more
  channels clip ((n - 1 + s) / 2, s rising as the next channel nears its clip).

Binned previews keep each channel the mean of its block and mark blocks with a
clipped sample in a mask (develop_binned_masked, reconstruct_masked), instead
of taking the block maximum: one clipped specular sample no longer becomes a
green speckle at 500 px (HUAWEI PRA-LX1, WAS-LX1A). The denoised twin uses the
same mask. Camera-colour fitting keeps the old binning.

RENDER_CACHE_VERSION 26 (adjust at merge).
2026-10-09 18:13:20 -04:00
Nathaniel LedfordandClaude Opus 5.5 f9d4892c23 Synchronize Folder: one undo step with a full history; partial failures reported (review fixes)
The steps to merge were counted from the undo history's length, which
stops growing at its 1000-step limit: with a full history nothing merged,
or only the last part was relabelled "Synchronize Folder" while the
import stayed behind it. Session now counts committed steps
(Session::commits) and Synchronize merges by that count.

Once the import is in, a later part that fails is listed in `failed`
instead of aborting with the import left as a separate step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:12:27 -04:00
Nathaniel LedfordandClaude Opus 5.5 5c7b655fe6 Synchronize Folder: Local files are new; moved files are relinked (review fixes)
A file only browsed in Local was counted as already in the library and
silently dropped; it is now new, and synchronizing brings it into the
library, as an import does.

A photo whose file was renamed or moved within the folder showed as
missing while its file counted as a skipped duplicate, so "Remove
missing" sent the photo and its edits to Recently Deleted and imported
nothing. The scan now matches a missing photo's content hash among the
folder's files and lists it as moved; synchronizing relinks it (on by
default, "Relink photos whose file was renamed or moved"), in the same
undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:10:48 -04:00
Nathaniel LedfordandClaude Opus 5.5 1259506edd Synchronize Folder: an absent folder is offline, disks only on request (review fix)
A folder that isn't there (moved whole, or on a disk that isn't
connected) used to scan as "every photo missing", one tick away from
moving a whole disk to Recently Deleted. The scan (on its worker, so an
asleep share never stalls the UI) now reports it as offline and both
commands refuse it; the dialog says so. A whole disk or a folder holding
disks needs `disk: true`, as Remove Disk from Library does; the disk rows
pass it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:08:36 -04:00
Nathaniel LedfordandClaude Opus 5.5 832d3c8d3d Synchronize Folder: no threads on the web (review fix)
scan_with ran the import scan beside the photo checks in a thread scope
on every target; on wasm32 a thread can't be spawned and Scope::spawn
panics, so Synchronize Folder… crashed the web build. There the two run
one after the other, like the other file work in the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:06:14 -04:00
Nathaniel LedfordandClaude Opus 5.5 dd3f860300 Docs: Synchronize Folder (parity, what's new)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:58:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 c520cf4574 UI: Synchronize Folder… dialog on Folders rows
Right-click a folder (or a disk other than the startup disk) in the
sidebar's Folders section ▸ Synchronize Folder…: the dialog opens at
once and scans on a worker thread (with progress), then offers Import
new photos / Remove missing photos / Read metadata updates with their
counts (Lightroom Classic's defaults: import only). Synchronize runs
folder.synchronize on exactly the scan shown; closing the dialog
cancels a running scan and lets go of the result. New strings are
translated in every shipped language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:57:32 -04:00
Nathaniel LedfordandClaude Opus 5.5 1e580466b5 Engine: scan a folder for changes concurrently and in parallel
The scan waits on the disk, not the CPU, so its two halves now run at
once: the look for new files (which probes on its own threads) beside
the checks of the library's photos (missing file, newer sidecar), which
run on several threads like the import probes do. A cancelled scan stops
checking. Results keep the photos' order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:57:32 -04:00
Nathaniel LedfordandClaude Opus 5.5 5345f0a3cf Engine: folder.synchronize acts on the scan the person saw
folder.scanChanges keeps its result in the session; folder.synchronize
uses it (once) while it is of the same folder and the catalog has not
changed since, so a scan made on a worker thread is not repeated on the
UI thread and files that arrive after the dialog was shown are left for
the next synchronize. Otherwise it scans again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:50:33 -04:00
Nathaniel LedfordandClaude Opus 5.5 eaa8828bb8 Engine: Synchronize Folder (folder.scanChanges, folder.synchronize)
A scan compares a library folder (and the folders inside it) with the
disk without changing anything: new files (the import scan's duplicate
rules), missing photos (the Missing Photos rule) and metadata updates
(XMP sidecars saved after the photo came in or was last edited here
that say something the library doesn't). It runs without the session
(SyncInput + scan_with) so the app can scan on a worker thread.

folder.synchronize imports the new files in place and, when asked,
moves missing photos to Recently Deleted and reads the updated
sidecars, all as one "Synchronize Folder" undo step. Only folders the
library holds photos in are accepted, never the startup disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:49:11 -04:00
radialmonster 4ece03a5df LR-IMP-FORMATS: decode Nikon "lossy after split" NEF
Rows below the split row use the existing lossy Huffman path; from the split
row on every pixel is a (bits - 4)-bit word: a complete prefix code for the
difference category followed by the top bits of the T.81 extra bits, dequantised
to the centre of the kept bin, with the usual two-left predictor and an
unclamped running value. The rule was derived by black-box analysis of the 11
CC0 samples (all decode to the same samples as the analysis decoder).

The decoder checks that the fixed-rate section ends within the last byte of the
strip (whole rows plus 0..7 padding bits) and otherwise keeps refusing the file
(preview only), so a file that does not follow the rule is never decoded wrongly.

RENDER_CACHE_VERSION 28 (main is 22, open PRs use 23-27): adjust at merge.
2026-10-09 17:48:15 -04:00
Nathaniel LedfordandClaude Opus 5.5 454b2bdd2f Docs: folder colour labels (parity, what's new, roadmap)
LRC-LIB-LIBFOLDERS lists folder.label and the Set Color Label menu, with
its limits (no filter or sort by folder label; labels don't follow a
folder moved outside LightCraft). What's new describes the feature and
catalog format v4. The roadmap notes folder records as the first step
towards Classic's folder-id model and when the next step would pay off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:40:20 -04:00
Nathaniel LedfordandClaude Opus 5.5 b26d5271b8 Folder labels: drop stale labels on any move; clear labels of empty folders
Review fixes. folder_records_follow now drops records left at the
destination even when the moved folder carries none, so an unlabelled
folder renamed onto an old path no longer inherits that path's label.
folder.label {label: none} works once the folder's photos are gone (a
label can no longer get stuck); setting a label still needs photos.

Adds tests for both, undo/redo of a labelled move, and labels replayed
from the journal without a snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:40:20 -04:00
Exalted5373andClaude Opus 5.5 90361935ba LR-IMP-FORMATS: HEIC decodes like libheif; release builds include it
heic-rs reconstructs iPhone HEVC tiles bit-exactly (checked against libde265), but
its colour stage was 2-27 code values off libheif on every iPhone photo: it ignores
the HEVC VUI and assumes BT.709 limited range. lightcraft-heif now converts YCbCr
itself (nclx, else VUI, else H.265 defaults; nearest chroma, bilinear for odd clap
offsets, as libheif), inherits tile ICC/nclx on grids, follows libheif's imir axis
and clap rounding, and outputs 16-bit RGB. Measured on 30 real and synthetic
files: within 1 code value of libheif (pillow-heif 1.8 / libheif 1.23).

Also: HEIF header reader for import (no pixel decode), embedded thumbnail for small
decodes, nclx/VUI colour hint (shared CICP mapping with PNG), clear 4:2:2/4:4:4
Unsupported error, Format::not_decodable as the one source of "why not" (import
reports "HEIC/HEIF support isn't included in this build"), `.heif` imports.
Release packaging and Nix build with the heif feature, as whats-new promised.
lightcraft_heif::testdata writes lossless (PCM) synthetic HEICs for the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:38:00 -07:00
Nathaniel LedfordandClaude Opus 5.5 c40a5675bb UI: Set Color Label on Folders rows, with the label's dot
Right-click a folder or disk row in the sidebar's Folders section ▸
Set Color Label ▸ a colour or None (folder.label). A labelled row shows
the label's dot before its count, and its spoken name includes the label.

The colour items are shared with the photo menu (grid::label_items):
each is now a control-channel widget (photoLabel:/folderLabel:<colour>),
and choosing None closes the photo menu like choosing a colour does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:33:31 -04:00
Nathaniel LedfordandClaude Opus 5.5 034991a081 Engine: folder.label command; labels follow folder rename and move
folder.label {path, label} gives a library folder a colour label (or
takes it off) as one undo step; only folders the library holds photos in
are accepted. library.folders reports each row's label. folder.rename and
folder.move commit the folder records with the photos' relinks, so the
labels of the folder and the folders inside it go along and undo/redo
carry them back and forth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:26:35 -04:00
Nathaniel LedfordandClaude Opus 5.5 309d659b99 Catalog: folder records with colour labels (format version 4)
Library folders can now carry what the library knows about them beyond
their photos: FolderRecord (for now a colour label), kept in
Catalog.folder_records under the folder's identity (folder_key) and
changed through Op::SetFolderRecord, one undo step. An empty record is
never kept; a path that names no folder is refused.

folder_records_follow builds the ops that carry a folder's records and
those of its subfolders to its new place on rename or move (dropping
stale records left at the destination), so they can be committed with
the photos' relinks. FolderNode reports the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:24:22 -04:00
radialmonster 37d3621505 LR-IMP-FORMATS: read the two-field 12-bit ORF layout of old Olympus compacts
C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ and SP-570UZ files say
uncompressed 12-bit with many strips whose byte counts sum to exactly
width x height x 1.5. main refused them because the 12-bit branch wanted a
single strip, so they fell to "Olympus compressed ORF" and opened
preview-only. The samples are packed MSB-first, the strips hold two fields
(even output rows, then odd ones) with one gap at the seam, and the last row
of the second field sits right after the last strip. Accepted only when all of
that holds exactly. CFA comes from the Exif tag, white balance from maker-note
entries 0x1017/0x1018, black 0. RENDER_CACHE_VERSION 27 (adjust at merge).
2026-10-09 17:23:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 4e9cffe375 LRC-LIB-KEYWORDS: names left out of a keyword list show on one line
A keyword with a line break in its name split the export toast in two.
Names left out now show escaped (Line\nbreak).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:02:07 -04:00
Nathaniel LedfordandClaude Opus 5.5 060da5bb5e LRC-LIB-KEYWORDS: keyword list export names only what it loses
Export now leaves out only what it must, and its counts are exact:

- A name starting with a byte-order mark is left out. On the first
  line, the reader would take the mark for the file's own.
- A synonym in braces is written, since it reads back. An empty
  synonym, or one that repeats another in a different case, is
  skipped quietly, as the reader would drop it anyway.
- keyword.export counts and checks for Capture One the keywords it
  actually wrote, now returned with the text. It used to guess from
  the lines, so a name starting with a brace was missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:02:07 -04:00
Nathaniel LedfordandClaude Opus 5.5 a970bdac51 LRC-LIB-KEYWORDS: document what keyword list files leave out
What's New now says that an unreadable list names its line or asks for
UTF-8, and that export names any keyword it had to leave out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:55:14 -04:00
Nathaniel LedfordandClaude Opus 5.5 2c912d4d1d LRC-LIB-KEYWORDS: Spanish and German keyword-list toasts read right for one
"Se añadieron 1 palabra clave" and "1 Stichwort/Stichwörter" didn't read
right. The counts now follow a label ("Palabras clave añadidas: 1",
"Stichwörter hinzugefügt: 1"), which reads right for any number, as the
Portuguese toasts already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:55:07 -04:00
Nathaniel LedfordandClaude Opus 5.5 c3b9a13557 LRC-LIB-KEYWORDS: Export Keywords… says why it failed
A list that couldn't be written, for example to a folder that's gone,
used to fail silently from the menu. Its error now shows as a toast, as
Import Keywords…'s already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:40 -04:00
Nathaniel LedfordandClaude Opus 5.5 e2aa4d9762 LR-BEHAV-TOAST: Auto-Tag from Tracklog's toast shows too
Like the other menu commands, Auto-Tag from Tracklog stamped its toast
with a throwaway context whose clock starts at zero. A while into a
session, the toast had already expired by the time it was drawn. It now
uses the app's context. Lightroom catalog import gets the app's context
too, so its background work repaints the window it belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:52:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 68c044990e LRC-LIB-KEYWORDS: keyword lists LightCraft can't read say what to do
A list saved as UTF-16, as Windows' Notepad can, now says to save it as
UTF-8 instead of only "not UTF-8 text". Text passed to keyword.import is
capped at 16 MB, like a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:51:35 -04:00
Nathaniel LedfordandClaude Opus 5.5 91e8a0ac37 LRC-LIB-KEYWORDS: an exported keyword list always reads back
The list format can't hold some names. A name in brackets or braces
would read back as a left-out keyword or a synonym. A line break or a
leading space would split it or break the indentation. A keyword nested
deeper than 64 levels won't read back either. These keywords, and those
inside them, are now left out of the file. keyword.export names them as
`unwritable`, and the toast says which.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:50:44 -04:00
Nathaniel LedfordandClaude Opus 5.5 9511132a31 LRC-LIB-KEYWORDS: big keyword lists import at once
Reading a list looked each line up in all the lines before it, and
importing looked each keyword up among every photo's keywords: a
40,000-line list into a 20,000-photo library took nearly two minutes.
Both now use an index built once, and the same import takes about a
second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:47:44 -04:00
Nathaniel LedfordandClaude Opus 5.5 02cdb15cfa LRC-LIB-KEYWORDS: keyword list files read as meant, or say why not
- Classic Mac line ends (a lone carriage return) are lines: a whole
  file used to become one keyword holding them.
- Indenting with spaces is refused at its line: the levels are tabs,
  and space-indented files (hand edits, editors that turn tabs into
  spaces) used to come in flat without a word.
- Control characters in a name, and "|" in a synonym, are refused.
- A keyword repeated in another case keeps its first spelling for the
  keywords inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:43:39 -04:00
Nathaniel LedfordandClaude Opus 5.5 fc4a7270b8 LRC-LIB-KEYWORDS: document keyword list files
The parity row covers File ▸ Import / Export Keywords… and the apps
that share the format, and lists what stays open (IPTC keywords in
exported files, hierarchy from other apps' XMP fields); What's New
explains it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:42:38 -04:00
Nathaniel LedfordandClaude Opus 5.5 8160148a8b LRC-LIB-KEYWORDS: File ▸ Import Keywords… and Export Keywords…
The File menu (beside the presets) imports a keyword list file
(Lightroom Classic's, Capture One's, Photo Supreme's .utf8) and exports
LightCraft's, with native file dialogs off the UI thread like the
other file commands; the desktop app gives the synchronous ones too.
Import says how many keywords it added and how many gained synonyms;
export says how many it wrote and names the keywords Capture One won't
import (; , < or > in a name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:42:38 -04:00
Nathaniel LedfordandClaude Opus 5.5 3d75952562 LR-BEHAV-TOAST: menu commands' toasts show
The UI's menu commands made their toasts with a fresh egui context,
whose clock starts at 0, while the app checks a toast's end against its
own clock: once the app had run a few seconds, every such toast (Auto
Advance On, presets imported or exported, the keyword painter, …) was
over before it was drawn. They use the app's context now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:33:41 -04:00
Nathaniel LedfordandClaude Opus 5.5 967edfcd80 LRC-LIB-KEYWORDS: keyword.import and keyword.export
- `keyword.export {path?}` writes the keyword list file (Lightroom
  Classic's format, which Capture One, Photo Supreme and Bridge read),
  or gives its text, and names the keywords Capture One's importer
  refuses (none of ; , < > may be in a list it reads).
- `keyword.import {path | text}` reads one (Photo Supreme's Formatted
  Vocabulary File too) as one undo step → {added, updated}. A line it
  can't read is named and nothing is taken; a file larger than 16 MB or
  not UTF-8 is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:29:09 -04:00
Nathaniel LedfordandClaude Opus 5.5 1d8d57ce9a LRC-LIB-KEYWORDS: Photo Supreme's vocabulary files read as keyword lists
Photo Supreme's Formatted Vocabulary File (Data ▸ Export to Formatted
Vocabulary File) turns out to be Lightroom Classic's keyword list
format: plain text with Windows line ends, a top-level category in
square brackets, keywords a tab further in at each level. A test with
a file of that shape pins it down; a real export of 118 keywords read
and wrote back line for line. (Photo Supreme reads Lightroom keyword
files too: Data ▸ Import from Lightroom Keywords file.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:27:16 -04:00
radialmonster 04e3b2f1aa LR-CLOUD-SMARTPREVIEW: smart previews record the camera-look fit version and are rebuilt when it is older
A smart preview keeps the colour and tone curves the camera-look fit gave it, so a
fit change (#499, #528, #583) left existing previews with the old look. They now
record LOOK_VERSION in the header; an older one is rebuilt from its original when the
library is opened or Build Smart Previews runs, and kept as it is while the original
is offline.
2026-10-09 16:26:32 -04:00
Nathaniel LedfordandClaude Opus 5.5 89091ada49 LRC-LIB-KEYWORDS: read and write keyword list files
Lightroom Classic's keyword list files (Metadata ▸ Import / Export
Keywords): a keyword a line, those inside it one tab further in, a
synonym in braces on a line of its own under its keyword, a keyword
left out of export in square brackets.
- `Catalog::keyword_list_text` writes the whole tree that way, by name.
- `keywords::parse_keyword_list` reads one: Windows line ends, a
  byte-order mark and blank lines are fine; a line it can't read (two
  levels down at once, a synonym of nothing, a "|" in a name, nesting
  past 64 levels: a file is input) is an error naming its line.
- `Catalog::import_keywords_ops` adds the keywords the library doesn't
  have, with the file's attributes and in the library's spelling of
  the levels it has; those it has keep their attributes and gain the
  file's synonyms. One undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:20:02 -04:00
Nathaniel LedfordandClaude Opus 5.5 5781b9c9f6 LRC-LIB-KEYWORDS: document saving a keyword set as a new one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:09:47 -04:00
Nathaniel LedfordandClaude Opus 5.5 4304a7640b LRC-LIB-KEYWORDS: Chinese names keyword sets as keyword sets
"Set name" (Edit Keyword Set, Save Keyword Set) read 标签集名称 /
標籤集名稱, the colour label sets' term; it is 关键字集名称 /
關鍵字集名稱, as the rest of the dialog says. "Edit Set…" says what it
edits: 编辑关键字集… / 編輯關鍵字集….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:09:15 -04:00
Nathaniel LedfordandClaude Opus 5.5 94d88dd4cc LRC-LIB-KEYWORDS: Edit Keyword Set refuses taken names in the user's words
- Edit Set… on Recent Keywords saves a new set (`new: true`): a name
  another set has is refused, where it used to replace that set.
- The dialog checks names before saving and says why in the user's
  language ("There is a keyword set “…” already"; Recent Keywords is
  built in), instead of showing the engine's message.
- A set deleted while its dialog was open is saved again, as a new set,
  rather than refused with no way out but Cancel.
- Save as a new set (Lightroom Classic's Save as New Preset) keeps the
  edited set and saves the edits under the new name.
Russian gets "Recent Keywords", which showed in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:09:01 -04:00
Nathaniel LedfordandClaude Opus 5.5 efa758a6ba LRC-LIB-KEYWORDS: keyword set names match whatever their case
Saving compared set names by any letter's case while using, deleting
and the current set compared ASCII case only: "été" saved over "ÉTÉ"
but couldn't use or delete it. Every set command (and Recent Keywords)
now compares names one way, as keywords are compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:07:00 -04:00
Nathaniel LedfordandClaude Opus 5.5 405e8d8220 LRC-LIB-KEYWORDS: a new keyword set doesn't take another set's name
Saving Recent Keywords as a set under the name of an existing one
(Edit Set… on Recent Keywords) replaced that set's keywords without a
word, while renaming onto a taken name was refused. `keyword.saveSet`
takes `new: true` for a set that must be new, and refuses a name
another set has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:06:31 -04:00
Nathaniel LedfordandClaude Opus 5.5 6fd57cdcb1 LRC-LIB-KEYWORDS: renaming a set alone keeps its keywords
`keyword.saveSet {name, replace}` without `keywords` took the current
set's keywords, so renaming a set that wasn't current gave it another
set's nine. The keywords default to the renamed set's own now (the
current nine only for a new set), and an empty `replace` is no rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:05:44 -04:00
Nathaniel LedfordandClaude Opus 5.5 1f8447ba79 LRC-LIB-KEYWORDS: document Edit Keyword Set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:59:55 -04:00
Nathaniel LedfordandClaude Opus 5.5 bbf2515c20 LRC-LIB-KEYWORDS: Edit Keyword Set's fields are alike
The nine fields took their width from what the dialog had while it
found its size: the first two columns came out squeezed ("ceremony"
showed "ceren") and the third wide. They are now equal columns of a
set width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:59:15 -04:00
Nathaniel LedfordandClaude Opus 5.5 68c1e78c1b LRC-LIB-KEYWORDS: Edit Keyword Set
The Keyword Set menu gets Edit Set… (Lightroom Classic's Edit Keyword
Set): the set's name and its nine keywords, one field per ⌥ key, typed
in place. Editing Recent Keywords and naming them saves a new set and
makes it current; editing a named set renames it in its place. An
empty field is an empty slot, shown as an idle button so the others
keep their keys. Return in any field saves; a set without a name says
so and the dialog stays open. Russian gets "Set name", and the label
check covers the new labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:57:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 6b4839f5fc LRC-LIB-KEYWORDS: keyword sets keep their slots, and can be renamed
For Edit Keyword Set (Lightroom Classic's), `keyword.saveSet`:
- keeps each slot in its place: an empty one ("") is an empty slot, so
  the keywords after it keep their ⌥ keys (empty ones were dropped and
  the rest moved up); trailing empty slots go, nine at most;
- holds each keyword once, whatever its case: a second one leaves its
  slot empty;
- with `replace`, renames that set in its place among the sets and
  keeps it current; a name another set has is refused.
⌥ on an empty slot does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:55:15 -04:00
radialmonster b4c4a436cb LR-IMP-FORMATS: treat DNG samples piled up below the stated white level as clipped (#548)
A DNG can state a white level the sensor never reaches (an iPhone 15 Pro Max Bayer DNG states 4095 but clips at 3567), so clipped highlights were not recognised and white balance tinted them magenta. On decode, samples that form a plateau clearly below the white level, with nothing above it, are raised to the white level. RENDER_CACHE_VERSION 25 (needs adjusting at merge).
2026-10-09 15:54:18 -04:00
Nathaniel LedfordandClaude Opus 5.5 dbf2c38ef3 LRC-LIB-KEYWORDS: document the Keywording box's three views
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:43:14 -04:00
Nathaniel LedfordandClaude Opus 5.5 9ae5a5f882 LRC-LIB-KEYWORDS: clearer German and Japanese for Will Export
German says "Es werden keine Stichwörter exportiert", the natural word
order; Japanese names the view "書き出されるキーワード" (the keywords
that will be exported) rather than the vaguer "書き出される内容".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:42:51 -04:00
Nathaniel LedfordandClaude Opus 5.5 4b5c7ccd3c LRC-LIB-KEYWORDS: a Keywords & Containing Keywords view
Lightroom Classic's Keywording box has three views; the box had two.
The switch now offers Keywords, & Containing (the selection's keywords
with the keywords containing them, flat and read only) and Will Export,
the two read-only views cached per selection. The label check covers
the box's other labels passed through variables too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:42:40 -04:00
Nathaniel LedfordandClaude Opus 5.5 fe02cfe2e1 LRC-LIB-KEYWORDS: Esc in Add keyword gives back the text from before
The box emptied itself on Esc, on top of the shared field's own undo of
the edit: text left there by an earlier edit (ended by clicking away)
went too. Esc now gives back what the box held before the edit, as
every text field does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:40:33 -04:00
Nathaniel LedfordandClaude Opus 5.5 c2bc46d747 LRC-LIB-KEYWORDS: the chip layout test checks what matters
`chips_stay_on_one_line` held by construction once chips were drawn at
a fixed height with their × on their own line. It now checks that every
chip is on one line inside the panel and that none overlaps another,
with a keyword longer than the panel among them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:39:54 -04:00
Nathaniel LedfordandClaude Opus 5.5 f5b1c09e42 LRC-LIB-KEYWORDS: chips speak, and keep their menu with their keyword
Drawn as one widget, a chip and its × lost the names a button gave
them: screen readers met unlabeled buttons. The chip is now named after
its whole keyword (even when cut short on screen) and the × "Remove
“…”". Both answer under ids made from their keyword rather than their
place in the row, so a chip's open menu stays with its keyword when the
chips change underneath it (an undo, a sync).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:39:04 -04:00
Nathaniel LedfordandClaude Opus 5.5 9dabd38941 LRC-LIB-KEYWORDS: keyword counts are of photos, fast with a big selection
- A photo listed twice in the selection counted twice, and an id the
  library doesn't have counted in "of": the chips, Will Export and the
  Keyword List's tick boxes now count the distinct selected photos.
- The chips looked each keyword's spelling up by scanning every photo
  (178 ms with 20,000 photos and 2,000 keywords selected, at every
  change while selected): they now take the keyword list's spelling
  (`Catalog::listed_path`) or the first one seen, 26 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:37:16 -04:00
Garrett Gonzalez-Rivas 4470d87dcb Nix: bump craft-fonts to 8dcdacd (the sane revision the release workflow uses) 2026-10-09 15:37:15 -04:00
Nathaniel LedfordandClaude Opus 5.5 5bb43c79cb LRC-LIB-KEYWORDS: a long chip keeps its name and asterisk
A chip too long for its row was cut with the file-path shortener, which
keeps the start: "Places › Portugal ›…" lost the keyword's own name and
the asterisk of a partial one. `chip_label` now cuts from the front
("…Lisbon › Belém tower *"). A chip also had two tooltips (its whole
name, and how many selected photos have it); it has one with both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:35:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 f2f164f012 LRC-LIB-KEYWORDS: a set keyword toggles off whatever its case
`keyword.toggleFromSet` checked whether every target photo had the
keyword by ASCII case, while the set's buttons and the rest of the
keyword code match any letter's case: with "ÄRZTE" and "ärzte" on the
photos the button showed on, but ⌥N added the keyword (a no-op) instead
of taking it off, so it could never be toggled off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:33:39 -04:00
Nathaniel LedfordandClaude Opus 5.5 69c91013e0 LRC-LIB-KEYWORDS: the active photo alone is the selection
Keyword commands act on the selected photos, or on the active photo
when the selection is empty (`Session::targets`), but the Keywording
box, the keyword set's buttons, the suggestions and the Keyword List's
tick boxes read the selection only: with just an active photo the box
showed nothing while typed keywords went to it. They now all use the
photos the commands use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:32:40 -04:00
Nathaniel LedfordandClaude Opus 5.5 03aa2edc9d LRC-LIB-KEYWORDS: document the selection-aware Keywording box
The keyword rows of the parity tracker describe the box and the chip
menu; What's New explains the chips, Will Export and what follows the
selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:26:28 -04:00
Nathaniel LedfordandClaude Opus 5.5 d73efbcb47 LRC-LIB-KEYWORDS: chips wrap whole
A chip was a frame laid out in what was left of its line: one that
didn't fit wrapped its own text, down to a letter a line, and pushed
its × to the next line. A chip is now one widget measured before it is
placed (its name cut short to fit the row), so the row moves it whole
to the next line; the Will Export names alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:26:07 -04:00
Nathaniel LedfordandClaude Opus 5.5 d6c9c32457 LRC-LIB-KEYWORDS: suggestions follow the selection
Suggestions were worked out from the active photo's keywords: a keyword
it had wasn't suggested though other selected photos lacked it. They
now leave out only the keywords every selected photo has, and build on
those for keywords used with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:23:20 -04:00
Nathaniel LedfordandClaude Opus 5.5 b501b7fffa LRC-LIB-KEYWORDS: the painter's keyword field is a shared text field
"Keyword to paint" gets the Cut / Copy / Paste / Select All menu and
Esc, and Return starts painting as the Paint button does. It stays
fixed while painting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:22:21 -04:00
Nathaniel LedfordandClaude Opus 5.5 f577ccf828 LRC-LIB-KEYWORDS: Will Export shows what exported files carry
Lightroom Classic's Keywording box can show the keywords that will be
exported rather than those entered. A Keywords / Will Export switch
under the box now does the same for the selection: the names exported
files carry as the keyword tag options say (a keyword left out of
export goes, parents and synonyms come), one only some of the photos
carry marked with an asterisk. Read only; cached per selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:21:13 -04:00
Nathaniel LedfordandClaude Opus 5.5 3e0c412249 LRC-LIB-KEYWORDS: the keyword set's buttons reflect the selection
The nine buttons showed "on" when the active photo had the keyword,
while their ⌥1–⌥9 toggle acts on every selected photo. A button is now
on when every selected photo has its keyword, and marked with an
asterisk when only some do, as the chips are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:19:13 -04:00
Nathaniel LedfordandClaude Opus 5.5 f83ac3377e LRC-LIB-KEYWORDS: the Add keyword box is a shared text field
It gets the Cut / Copy / Paste / Select All menu (paste a list of
keywords from elsewhere), Return gives the keywords to every selected
photo as before, and Esc gives up what was typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:18:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 a3b7c4caf4 LRC-LIB-KEYWORDS: a chip's menu acts on the selection
The chip menu offered Delete Keyword, which takes the keyword off every
photo in the library, beside "Remove from Photo". It now acts on the
selected photos only: Add to All Selected Photos (for a keyword only
some have), Remove from Selected Photos, Show Photos with Keyword, and
Edit Keyword Tag… (the Keyword List's dialog, for renaming too).
Deleting a keyword from the library is the Keyword List's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:17:25 -04:00
Exalted5373andClaude Opus 5.5 c732452d35 LR-IMP-PROFILES: red / green / blue curves only with the master and all three channel curves
Lightroom Classic 15.6 imports an XMP packet's channel curves only when it holds
ToneCurvePV2012 and all three of ToneCurvePV2012Red/Green/Blue; any partial set
(master + one or two channels, channels without the master) imports without
channel curves. #416 read them whenever the master curve was present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:17:03 -07:00
Nathaniel LedfordandClaude Opus 5.5 4cb024164e LRC-LIB-KEYWORDS: the Keywording box shows the selection's keywords
The chips at the top of the Keywords panel showed the active photo's
keywords, while adding and removing applied to every selected photo:
with several selected they misreported what the selection had. They
are now the keywords of every selected photo, each once whatever its
case (`panels::keywording`, cached per selection), and one only some
of them have is marked with an asterisk ("On 1 of 2 selected photos").
Only the × removes a keyword, from every selected photo: a click on
the chip itself used to remove it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:16:36 -04:00
Alex ChambersandClaude Opus 5.5 f1fa336ab1 LR-IMP-CAMERA-COVERAGE: Sony 16:9 crop on pre-crop-tag bodies, refuse the encrypted DSC-R1 SR2
Part of #535 (items 4 and 5).

- Pre-2017 bodies without Sony crop tags shot in the camera's 16:9 mode now get
  the 16:9 crop of their camera JPEG: width from FullImageSize, anchored left as
  before (drops the ILCE-7SM2's 32 padding columns); height from FullImageSize
  (ILCE-7SM2) or from an Exif image size of the same width (DSLR-A580, whose
  FullImageSize still says 3:2); centred vertically at an even offset, as
  measured by registering each camera JPEG on its raw. The aspect now matches
  the JPEG, so the camera-look fit runs: ILCE-7SM2 dE 15.2 -> 4.8,
  DSLR-A580 dE 26.7 -> 3.9 (vs main 88ea1a8).
- The DSC-R1's SR2 (maker-note FileFormat 1 0 0 0) stores encrypted samples and
  decoded to noise; it is now Unsupported in full and header mode and imports
  preview-only with that reason.
- Only these 3 of 140 Sony decodes change; render cache version 26 (23-25 are
  taken by #568, #542, #575).
- Tests: crop helper (3:2 unchanged, both 16:9 sources, even offsets, hostile
  sizes), synthetic Sony file with FileFormat SR2 vs ARW, corpus test of the
  two crops and the SR2 refusal; three samples added to the corpus list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:10:16 +02:00
Nathaniel LedfordandClaude Opus 5.5 2e016f73a6 LRC-LIB-KEYWORDS: the docs say what the keyword options do
The parity row said Person shapes exported files: it is stored only.
It now also covers the list without a selection, the default parent's
reach (typed keywords too) and keywords' hierarchy on import. The
synonyms' doc no longer says search finds them, which it doesn't yet.
What's New follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:12 -04:00
Nathaniel LedfordandClaude Opus 5.5 228561d4c9 LRC-LIB-KEYWORDS: idle controls in the Keyword List say nothing
With nothing selected a tick box still offered "Add to Selected
Photos", and while a filter holds the levels open the triangle lit up
on hover though a click does nothing. Neither does now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:29 -04:00
Nathaniel LedfordandClaude Opus 5.5 decebccb89 LRC-LIB-KEYWORDS: dropping photos on a keyword counts those that get it
The toast said every dropped photo got the keyword, those that had it
already included. It now counts the photos that didn't have it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:02 -04:00
Nathaniel LedfordandClaude Opus 5.5 334868b73a LRC-LIB-KEYWORDS: typed new keywords go inside the default parent
Lightroom Classic puts new keywords typed for photos inside the keyword
marked Put New Keywords Inside This Keyword; only + did here. The
Keywords panel's box now does too (`Catalog::typed_keyword`): a new
name goes inside the default parent, while a keyword the library has,
or a path typed whole, stays as typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:03:16 -04:00
Nathaniel LedfordandClaude Opus 5.5 8fcc4897c7 LRC-LIB-KEYWORDS: keyword translations: Person, merging, export
- Russian had no "Person": the option showed in English (it reaches
  `tr` through a variable). A test now checks that every label the
  Keyword List passes that way is in every language's file.
- The merge confirmation's button said "Merge", which some languages
  translate as merging photos (HDR, panorama): it says "Merge Keywords".
- Traditional Chinese uses 匯出 for Export, as everywhere else in the
  file, not 轉存; "Export Containing Keywords" means the parent
  keywords in both Chinese files (上層 / 上级), not "included" ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:02:02 -04:00
Nathaniel LedfordandClaude Opus 5.5 4010f4a466 LRC-LIB-KEYWORDS: a keyword drag ends with Esc, or wherever it is released
The Keyword List ended a keyword drag only while it was drawn: with
another panel opened mid-drag, the drag lingered until the list came
back. `keyword_list::drag_feedback` now runs every frame, like the
album drag's: the drag ends with the button's release wherever it is,
or with Esc. A press on a tick box that moves no longer picks the
keyword up. The test that a keyword can't be dropped inside itself
now checks that no move was even tried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:00:40 -04:00
Nathaniel LedfordandClaude Opus 5.5 23ce79009d LRC-LIB-KEYWORDS: the Keyword List follows its keywords
After a rename (Edit Keyword Tag), a drag or a merge made in the list,
the picked keyword still named the old path (the highlight went and −
was greyed out for a keyword that was there) and the renamed levels
closed. The pick and the open levels now follow the keyword, and the
levels containing it open, so a keyword dropped into another stays in
sight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:58:45 -04:00
Alex ChambersandClaude Opus 5.5 179910c020 LR-IMP-CAMERA-COVERAGE: Sony ARW white balance and black level from one SR2SubIFD decryptor
Part of #535 (item 1). Stacked on #542 (SR2 black levels); supersedes its
positional keystream tables.

- White balance is WB_RGGBLevels (0x7313), the gains the camera applied:
  the raw IFD's plain copy when present, else the copy in the encrypted
  SR2SubIFD, which every ARW from the DSLR-A200 on carries (identical to
  the plain one on all 57 CC0 files that have both). The maker note's
  Tag2010 gains stay only as a last resort.
- 14 bodies had no white balance and rendered green whenever the camera
  look was rejected (A450 ... A700, NEX-3/5/C3, SLT-A33/A35/A55,
  ILCE-6001, UMC-R10C). On others Tag2010 missed the applied gains by up
  to 100 % (ILCE-3500 at 5600 K: R -31 / B +101 % against the camera
  JPEG's neutral pixels, now +4 / +0 %; ILCE-7S on Shade -9 / +15 ->
  -1 / +1 %; SLT-A57 -25 / +26 -> -6 / -3 %).
- New vendor/sr2.rs decrypts the whole SR2SubIFD, found black-box:
  exiftool -v5's decrypted dump as known plaintext gives the keystream
  (key 11 22 33 44, the same at every position in 126 CC0 ARWs from 97
  bodies, 15 layouts); an exhaustive search over lag pairs found
  k[n] = k[n-63] ^ k[n-127] on 32-bit words, so the first 127 words
  generate the rest, matching all 126 files (5.2 MB of keystream).
- The SR2 black level (0x7310) is now read through the same decryptor,
  replacing #542's head and per-position keystream tables: one SR2
  decryption path. Identical results on all 131 Sony files (every
  value #542 decoded; the 51 it could not are the files' plain 0x7310).
  Unit tests pin the keystream against answers recorded without the
  generator: every byte #542 and #148 had recovered, the SHA-256 of the
  whole 62112-byte keystream recovered from the CC0 DSLR-A700 sample, and
  the encrypted WB_RGGBLevels of four CC0 files (one per layout) with the
  levels ExifTool decrypts.
- Sweep (4f350c4 -> this): gains equal ExifTool's decrypted
  WB_RGGBLevels on all 100 decoded CC0 CFA files; on 83 files with
  >= 200 JPEG-neutral pixels, within 10 % of the camera's applied gains
  71 -> 83, over 10 % 5 -> 0. Accepted fits unchanged (max 0.15 dE);
  forced fallback median dE 18.50 -> 18.06, neutral-area cast on the 22
  changed files 8.8 -> 1.2.
- Corpus test for five CC0 samples (A500, A33, A700, ILCE-3500 5600 K,
  ILCE-7S Shade; three added to RAW_SAMPLES); the pre-2017 test's gains
  move to WB_RGGBLevels (RX100 III, RX100, ILCE-7RM2: within 2.6 % of
  the old Tag2010 values in Auto). Synthetic tests for decryption,
  directory parsing, source precedence, black level through the full
  DNGPrivateData chain, and hostile input; #542's layout tests (incl.
  the A700 trap) now run through the decryptor.
- Render cache version 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:58:44 +02:00
Nathaniel LedfordandClaude Opus 5.5 92730c9246 LRC-LIB-KEYWORDS: Return in Synonyms creates the keyword
In Create / Edit Keyword Tag only the name field answered Return: typed
synonyms followed by Return did nothing. Return in Synonyms now creates
(or saves) the keyword too, as a dialog's default button does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:56:40 -04:00
Nathaniel LedfordandClaude Opus 5.5 344db72c85 LRC-LIB-KEYWORDS: the Keyword List needs no selection
With no photo active the whole right panel said "No photo selected",
so the library's Keyword List disappeared: keywords couldn't be made
or organized before choosing a photo. The Keywords panel now shows the
list without a selection (its tick boxes wait for one), under a note
that selecting photos gives them keywords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:56:01 -04:00
Nathaniel LedfordandClaude Opus 5.5 5d4ebe5132 LRC-LIB-KEYWORDS: drops land only on what is shown
The Keyword List took a drop wherever the pointer was inside a row's or
the title's rectangle, even where the panel had scrolled it out of
sight: a keyword released over the app's top bar, where the list's
title was hidden, went to the top level. Drops now need the pointer on
the visible part of the panel too; photos dropped from the grid alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:55:15 -04:00
Alex ChambersandClaude Opus 5.5 3100cc1a5c LR-IMP-CAMERA-COVERAGE: Sony black levels from the SR2 layout and the lossless M/S pedestal
Part of #535 (items 2 and 3).

- SR2SubIFD black level: decrypt the directory's first entry (always the
  black level, 0x7310) to find where its value sits, and decrypt it with the
  keystream known for that position (1638, 2166, 2418, 2510). The fixed read at
  2510 gave 354-365 on the DSLR-A450/A500/A550 and 975 on the DSLR-A700, where
  ExifTool and the data floor say 512 (A700 render: dE 20.1 -> 8.8,
  dL* -9.8 -> 0.0). Keystreams recovered black-box (plain 0x7310 and exiftool -v3
  as known plaintext), consistent over 115 ARWs from 97 bodies, 15 layouts.
- Downsized lossless M/S ARWs (linear YCbCr): black 1024 and white 16895, i.e.
  the recorded 512 plus 512, measured against same-scene full-size codings on
  12 files from 4 bodies (offset 1015-1052, scale 0.98-1.02). The 512 pedestal
  lifted shadows by ~3 % of white; M/S renders now match their full-size twins
  (7RM5 M dE 6.7 -> 3.1).
- Only these 16 of 140 Sony files change; render cache version 24.
- Tests: synthetic SR2 blocks for every known layout (incl. the A700 trap),
  YCbCr black/white; corpus test of black levels against the data floor;
  A500 and A700 samples added to the corpus list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:55:03 +02:00
Nathaniel LedfordandClaude Opus 5.5 199e33043f LRC-LIB-KEYWORDS: the Keyword List stays fast with a big selection
Every row worked out its tick box from every selected photo's keywords,
every frame: with 20,000 photos selected a frame took seconds. `Ticks`
now counts each keyword over the selection once, cached by library
revision and selection, and each row looks its count up: 2.8 ms a
frame for 20,024 selected photos. The check that the picked keyword
still exists reads the cached tree instead of scanning every photo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:54:15 -04:00
Alex ChambersandClaude Opus 5.5 dc37496da4 LR-PROF-CAMERACOLOR: bundled Sony ILCE-7CR camera profile
Fitted with `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.

Without it, 8 of the 131 photos fail the fit gates and open with the
neutral matrix (dE76 6-26 against the camera JPEG). On 26 withheld photos
(profile fitted to the other 105) mean dE76 goes 4.07 -> 3.29 and both
withheld rejections are accepted (26.0 -> 6.1, 6.2 -> 5.5); the 24
withheld photos whose own fit was accepted are about unchanged (12
improve, mean 3.06 -> 3.08, worst regression 1.8). With the final profile
all 131 are accepted. The nine public raw.pixls.us ILCE-7CR codings stay
accepted (mean 1.59 -> 1.99).

A corpus test checks the public lossless-L sample's fit keeps exactly the
built-in profile's matrix and table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:52:47 +02:00
Nathaniel LedfordandClaude Opus 5.5 c80318667d LRC-LIB-KEYWORDS: keyword actions count the photos they change
Rename, merge and delete answered `changed` with every change they
made, so with the keyword list it no longer meant photos (renaming a
keyword no photo has said 2). `changed` is photos again, and `listed`
counts the keyword list's changes (what Purge reports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:51:26 -04:00
Alex ChambersandClaude Opus 5.5 f48122992b LR-IMP-CAMERA-COVERAGE: ILCE-7CR test pins the black level of Bayer codings only
The YCbCr M coding's pedestal is corrected separately (#535, item 3), so
this test no longer depends on which change lands first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:50:32 +02:00
Alex ChambersandClaude Opus 5.5 ea080e58bd LR-IMP-CAMERA-COVERAGE: Sony ILCE-7CR samples in the corpus
Three CC0 raw.pixls.us files of one ILCE-7CR (61 MP) scene: lossless
compressed L and M (subsampled YCbCr) and compressed ARW2. A raw corpus
test pins model, colour-filter layout, black level, white level, as-shot
gains and default crop, and checks that the lossless and compressed
mosaics agree inside the crop (green block correlation 0.9976). An engine
corpus test checks each gets a fitted camera look, not the neutral
fallback. All nine public codings decode and fit (mean dE76 1.59 against
their camera JPEGs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:50:32 +02:00
Nathaniel LedfordandClaude Opus 5.5 9e1abe5a97 LRC-LIB-KEYWORDS: typing suggests keywords no photo has yet
Suggestions came from the photos' keywords only, so a keyword created
in the Keyword List was never offered while typing. Those only the list
has are suggested too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:50:13 -04:00
Nathaniel LedfordandClaude Opus 5.5 f0ac4900ba LRC-LIB-KEYWORDS: an edit that changes nothing is nothing
Editing a keyword without changing it listed it (when it wasn't) and
made an undo step. The attributes are now set only when they change
something: a keyword only photos have, renamed with the default
attributes, stays unlisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:50:03 -04:00
Nathaniel LedfordandClaude Opus 5.5 fad169fa47 LRC-LIB-KEYWORDS: a new keyword keeps the spelling of its parents
Creating "TRAVEL|Rome" next to "travel|italy" listed it as typed, and
the tree, which takes a level's spelling from the list first, showed
"TRAVEL". A new keyword now takes the library's spelling of each level
it shares with a keyword there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:49:54 -04:00
Hendrik 6831983a4e DOCS-CODEX: document Codex MCP setup and external libraries 2026-10-09 20:48:26 +02:00
Nathaniel LedfordandClaude Opus 5.5 73175851e7 LRC-LIB-KEYWORDS: keywords given to photos are stored cleaned
`photo.setMeta` stored keywords as given: " beach " showed in the tree
as "beach" but couldn't be found there (Edit, Move, Delete said there
was no such keyword), and duplicates were caught by ASCII case only.
Keywords are now stored cleaned (`a | b ` is `a|b`, empty ones are
dropped), each once whatever the case of any letter, and removing one
matches it the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:47:38 -04:00
Nathaniel LedfordandClaude Opus 5.5 3a145eef4e LRC-LIB-KEYWORDS: keywords from files keep their hierarchy
Exports now write names to `dc:subject` and paths to
`lr:hierarchicalSubject`, as Lightroom Classic does, but import and
sidecars read only `dc:subject`: an exported `travel|Italy|Rome` came
back as three keywords, Rome, Italy and travel. They now take the
paths of `lr:hierarchicalSubject` and the names of `dc:subject` that
aren't levels of those paths (`keywords::from_file`); without paths,
the names as they are, so LightCraft's own sidecars read as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:46:39 -04:00
Nathaniel LedfordandClaude Opus 5.5 d62a5637d8 LRC-LIB-KEYWORDS: the filter follows a keyword moved into a new parent
`keyword.move` worked out where the keyword landed from the parent's
spelling in the library, and fell back to the top level when the
parent didn't exist yet, while the catalog made that parent: the
keyword filter stayed on the old path and the grid showed nothing. It
now lands the filter where the catalog puts the keyword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:44:41 -04:00
Nathaniel LedfordandClaude Opus 5.5 c01bdbbe9b LRC-LIB-KEYWORDS: the default parent belongs to its keyword in the library
"Put New Keywords Inside This Keyword" was a session preference that
followed renames by hand: undo and redo never moved it back, so after
undoing a rename it named a keyword that no longer existed and the
next new keyword silently made that parent again. It is now a mark on
the keyword in the catalog's keyword list (`new_keywords_inside`, one
keyword at most): it moves with its keyword (into a merge too), goes
with it, comes back with undo and redo, and is kept with the library.
Setting it is one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:43:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 f0689d3271 LRC-LIB-KEYWORDS: merging overlapping keywords keeps every listing
Merging `a|b` and `a|c` into `a` moved the listings one at a time: the
`a|b|c` listing landed on `a|c` before that one left for `a`, merged
into it, and its attributes were lost. Every moving listing now leaves
first, then each lands. Each keyword also moves by the closest keyword
merged (`a|b` before `a`), so the order they are given in doesn't
change the result; the keyword filter follows the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:41:20 -04:00
Nathaniel LedfordandClaude Opus 5.5 3c9a6d7459 LRC-LIB-KEYWORDS: photos in Recently Deleted don't hold keywords
The tree and the counts leave out photos in Recently Deleted (and those
only browsed), but `keyword_path`, `has_keyword` and purging read every
photo: a keyword only a deleted photo had couldn't be created again,
wasn't purged, and could lend its spelling. They now read library
photos only, as the tree does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:39:53 -04:00
radialmonster 44497edca4 LR-PROF-CAMERACOLOR: docs: partial camera look, fallback diagnosis and archive numbers 2026-10-09 14:31:41 -04:00
Nathaniel LedfordandClaude Opus 5.5 c743239705 LRC-LIB-KEYWORDS: document the Keyword List
The LRC-LIB-KEYWORDS row lists the Keyword List, its commands and what
isn't there yet; LR-LIB-KEYWORD no longer says keywords can't be
dragged. What's New describes the list, its dialogs, drag and drop and
the export options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:31:35 -04:00
Nathaniel LedfordandClaude Opus 5.5 debd2ee2ec LRC-LIB-KEYWORDS: reorganize keywords by drag and drop
In the Keyword List:
- dragging a keyword onto another nests it there, with the keywords
  below it and its photos; dropping it on the list's title (which
  says "Drop here for the top level" during the drag) takes it to the
  top level;
- a keyword is never outlined, nor dropped, inside itself or one of
  its children;
- where a keyword of its name is already, a dialog asks before merging
  the two;
- photos dragged from the grid onto a keyword get it.
The dragged keyword's name follows the pointer; the drag ends with the
button's release, wherever it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 f1c513c20c LRC-LIB-KEYWORDS: a keyword's context menu in the Keyword List
Right-clicking a keyword offers Create Keyword Tag Inside it, Edit
Keyword Tag, Put New Keywords Inside This Keyword (the list marks that
keyword with a dot), Add to / Remove from Selected Photos, Show Photos
with Keyword, Purge Unused Keywords and Delete Keyword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:27:22 -04:00
Nathaniel LedfordandClaude Opus 5.5 c01eaadbf3 LRC-LIB-KEYWORDS: the parent of a listed keyword is a keyword too
"Events" of a listed "Events|Weddings" is in the tree, but
`has_keyword` and `keyword_path` only looked at photos and the listing
itself: it couldn't be renamed, moved or made the default parent. They
now find a keyword through the listed keywords below it as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:27:22 -04:00
Nathaniel LedfordandClaude Opus 5.5 41386353e9 LRC-LIB-KEYWORDS: create, edit and delete keywords from the Keyword List
- The + button opens Create Keyword Tag: a name, synonyms, "Put inside"
  the keyword picked in the list (or the default parent), "Add to
  Selected Photos", and the keyword tag options (Include on Export,
  Export Containing Keywords, Export Synonyms, Person). Return creates
  it; a name that is taken says so and keeps the dialog open.
- Double-clicking a keyword opens Edit Keyword Tag with its name and
  attributes; a new name and options apply in one undo step.
- The − button deletes the picked keyword after saying how many photos
  lose it.
The name takes the focus once when the dialog opens: the one-field
dialogs' `dialog_field` takes it back every frame, which would steal it
from Synonyms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:25:02 -04:00
midasdf c0ff290e7e M1: retain selection after permanent photo deletion
Reuse the landed current-view deletion selection logic for photo.deletePermanently, retaining survivors or choosing the next neighbour and the previous at the end. Preserve catalog-only deletion and existing undo/persistence behavior.

Refs #441, #451, #545.
2026-10-10 03:24:49 +09:00
kelannaandClaude Opus 5.5 3bcc265bf6 LR-PREF-DISPLAY: monitor ICC profiles (display colour management)
Previews were always sRGB, which wide-gamut monitors show oversaturated.
Settings > Display > Choose Profile... (and the UI command
app.displayProfile) now shows photos through the monitor's ICC profile,
matrix/TRC or LUT-based.

- codecs: DisplayProfile (moxcms): the display's primaries (colorant tags,
  or measured through the A2B tables of a LUT-only profile), the proxy ->
  device correction (per-channel curve tables for matrix/TRC profiles, a
  CMS transform for LUT profiles) and sRGB <-> display transforms.
- pipeline: DisplaySpace / RenderRequest::display: the per-pixel stage
  renders into the display's primaries and gamut maps into its gamut
  (CPU and GPU: only to_out / out_luma change). Soft proofing's display
  gamut warning uses the display's gamut.
- engine: RenderJob::with_display; display::present converts sRGB
  results (thumbnails, cached previews, embedded JPEGs). Histograms and
  the preview caches stay sRGB; exports are unchanged.
- ui: the loupe, Before, Compare, hover and second window render for the
  display; everything else is converted on the render workers. Settings >
  Display tab, ui.json setting, file picker opening in the system's ICC
  folder.

Tests: profile parsing and transforms (sRGB, P3, gamma 2.2, a synthetic
LUT-only profile, corrupted files), display renders equal 8-bit renders
into the matching output space, sampled histograms, and a headless UI test
of the whole flow. docs/display-profiles.md describes the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEuxxPyG8t6KcADbZSEAUj
2026-10-09 11:21:09 -07:00
Nathaniel LedfordandClaude Opus 5.5 a29c9f0de5 LRC-LIB-KEYWORDS: a Keyword List in the right panel
The Keywords panel ends with Lightroom Classic's Keyword List: every
keyword of the library, those without photos too, as a tree with photo
counts (`panels::keyword_list`).
- A filter box keeps the keywords whose name holds the text, with the
  keywords containing them opened down to them.
- The triangle opens a level (kept with the UI state).
- The tick box says whether every, some (a dash) or none of the
  selected photos have the keyword, and gives it to all of them or
  takes it away.
- The arrow shown on hover shows the photos with the keyword.
- A click picks a row, for the actions that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:21:04 -04:00
Nathaniel LedfordandClaude Opus 5.5 96dcc2f643 LRC-LIB-KEYWORDS: keyword names match whatever the case of any letter
`is_under` and `dedupe` compared names by lower-casing them, but other
checks used `eq_ignore_ascii_case`: changing only the case of "Ärzte"
was refused as a clash with itself, and moving a keyword where it is
wasn't seen as staying put. Every comparison now uses
`keywords::same`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:16:53 -04:00
Jason Baldwin a677478b92 KEY-ZOOM: Cmd+= / Cmd+- / Cmd+0 zoom the photo, not egui's interface scale (#566)
egui's zoom_with_keyboard was left on, so at the end of every frame it took the
same keys as View ▸ Zoom In / Zoom Out / Zoom to Fit for its browser-style
interface zoom. On macOS the native menu bar got the keys first; on Windows and
Linux the whole UI scaled. theme::apply turns the option off for every context
(desktop, headless, tests).

Headless tests that pressed Cmd+Plus / Cmd+Minus to exercise the interface
zoom's viewport handling set the zoom factor directly; the CLI snapshot test
that did the same is dropped (the unit tests cover the mechanism). New
tests_zoom_keys.rs: the shortcuts step the photo zoom while the zoom factor
stays, and Ctrl+wheel over the photo zooms it in the Edit view.
2026-10-09 20:14:49 +02:00
Nathaniel LedfordandClaude Opus 5.5 296fc17376 LRC-LIB-KEYWORDS: exported files carry keywords as their options say
Export wrote each keyword's `a|b` path into `dc:subject` and nothing
into `lr:hierarchicalSubject`. It now writes what
`Catalog::export_keywords` gives: names flat in `dc:subject` (with the
keywords containing them and synonyms, as their options say), full
paths in `lr:hierarchicalSubject`, and nothing of a keyword left out
of export. `export_metadata` takes the catalog for the keyword list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:13:14 -04:00
Nathaniel LedfordandClaude Opus 5.5 10c98459fe LRC-LIB-KEYWORDS: keyword list commands and the default parent
- `keyword.create {name, parent?, attributes…, addToSelected?}`: a
  keyword inside a parent with its attributes, given to the selection
  at once; one undo step.
- `keyword.edit {keyword, name?, attributes…}`: what isn't given keeps
  its value.
- `keyword.move {keyword, parent | null, merge?}`: onto a taken name
  it says so, and merges only with `merge: true`.
- `keyword.purgeUnused`, `keyword.info` (attributes, listed, count).
- `keyword.setDefaultParent` (Lightroom Classic's "Put New Keywords
  Inside this Keyword"): new keywords go inside it unless `parent` is
  given (null = the top level). It is kept with the library, follows
  its keyword's rename or move, and goes when the keyword is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:12:15 -04:00
Alex ChambersandClaude Opus 5.5 5a61fc5f67 LR-PROF-CAMERACOLOR: Sony ARWs shot with DRO start without the camera JPEG's lifted shadows (#244)
Sony's Dynamic Range Optimizer (factory default Auto) brightens the darker
regions of the camera JPEG, not the raw. The tone curve fitted to that JPEG
carried the brightening into the raw's starting look: on 8 ILCE-7CR photos
with DRO on, LightCraft rendered +6.4 L* from Lightroom's default (median
dE76 7.5), against -0.2 L* (3.6) for the 16 with DRO off.

- lightcraft_raw::embedded_preview_dynamic_range_optimized reads Sony
  maker-note tag 0xb025 (every documented value: 0 off; 1-3, 8-12 and
  16-23, i.e. up to Lv8, on). It was present on all 264 Sony raws checked
  (98 bodies); 0xb04f only says on/off and is missing on older bodies.
- With DRO on, the look keeps the colour fitted to the JPEG and its tone
  curve is lowered, knot by knot, to Sony's curve without DRO wherever that
  is darker (assets/camera-tone/sony-dro-off.json, pooled from 12 CC0
  raw.pixls.us ARWs of 10 interchangeable-lens bodies; sources and
  derivation in the file and assets/ATTRIBUTION.md). The second-last knot
  is lowered only as far as keeps the shoulder past the last knot at or
  below the fitted curve, so the result is never brighter anywhere.
  Compacts that map raw values darker keep their own curve.
- DRO off, unknown and other makers: unchanged (byte-identical renders on
  71 + 35 photos). Render cache version 23.

DRO-on photos now land at +0.8 L* from Lightroom (median dE76 3.3), band by
band like the DRO-off ones. Docs: camera-preview-colour.md, parity row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:09:16 +02:00
Nathaniel LedfordandClaude Opus 5.5 cdca2b9cf8 LRC-LIB-KEYWORDS: the keyword filter follows a rename without panicking
When a keyword was renamed or merged, the keyword filter followed it by
cutting the old name off by bytes: names match whatever their case, and
where a letter's case changes its length (ẞ / ß) the cut could land
inside a letter and panic (`ẞẞA` → `Road` with the filter on `ßßa|é`).
It now uses the catalog's level-based `reparent`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:08:37 -04:00
Dmytro MatviichukandCodex b08f80700f M16: add Ukrainian interface translation (#558)
Add Ukrainian catalogs, locale selection, context-aware labels and regression coverage.
Record permissive sibling-catalog provenance in the existing attribution index.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:07:44 +02:00
Nathaniel LedfordandClaude Opus 5.5 7029c8fbbd LRC-LIB-KEYWORDS: the keywords an exported file carries
`Catalog::export_keywords` applies Lightroom Classic's keyword tag
options: a keyword left out of export goes nowhere; otherwise its name
and the keywords containing it that are exported themselves (unless
"export containing keywords" is off) and its synonyms (unless "export
synonyms" is off) go out flat, for `dc:subject`, and its full path for
`lr:hierarchicalSubject`. Each name once, whatever its case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:06:34 -04:00
Nathaniel LedfordandClaude Opus 5.5 fba3bdef2c LRC-LIB-KEYWORDS: create, move, edit and purge keywords
The keyword list's own actions, each one undo step:
- create a keyword with its attributes, and tag photos with it at once;
  one that exists already (listed or on a photo) is refused;
- move a keyword (with those below it) inside another or to the top
  level; never inside itself or its children, and onto a name that is
  taken only when merging is allowed (`CatalogError::KeywordExists`
  otherwise, so the UI can ask);
- edit: rename its last level and set its attributes together; a taken
  name is refused, merging being its own action;
- purge: take off the list the keywords no photo has.
Typed in any case, keywords keep the spelling the library has
(`keyword_path`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:05:38 -04:00
Nathaniel LedfordandClaude Opus 5.5 cd8dfa72c2 LRC-LIB-KEYWORDS: renaming keeps the levels below whatever the case
Renaming a keyword cut the keywords below it at the byte length of the
name as typed. Names match whatever their case, but some letters change
length with it (ẞ is three bytes, ß two): `STRAẞE` → `Road` turned
`straße|nord` into `Roadnord`, and a cut inside a letter would panic.
The levels below are now kept whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:05:25 -04:00
Nathaniel LedfordandClaude Opus 5.5 411daefa68 LRC-LIB-KEYWORDS: rename, merge and delete carry the keyword list along
Renaming or moving a keyword takes its listing and its listed children
along, photos or not; a keyword only the list holds can be renamed too.
Merging keeps the target's attributes and gathers the synonyms (a
target not listed yet takes the merged keyword's attributes). Deleting
takes the keyword and those below it off the list. Each is still one
undo step: the list's changes join the photos' in the batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:02:27 -04:00
Nathaniel LedfordandClaude Opus 5.5 21cd3b75fd LRC-LIB-KEYWORDS: a keyword list that holds keywords without photos
Keywords existed only as `a|b|c` strings on photos: one couldn't be
created before a photo had it, nor a parent made to organize under.
The catalog now has a keyword list (`Op::SetKeyword`, format version
4): a keyword listed on its own is in the tree with no photos, its
parents too, and carries Lightroom Classic's keyword tag options
(`KeywordInfo`: synonyms, include on export, export containing
keywords, export synonyms, person), exported by default. Undo restores
what was listed, as it was written. Version 3 libraries open and are
rewritten as 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:00:13 -04:00
radialmonster a33570ca73 Merge main into sony-a900-packed 2026-10-09 13:57:54 -04:00
radialmonster 5ed59252e8 LR-IMP-FORMATS: decode packed 12-bit Sony ARW (DSLR-A900) from its exact 1.5 bytes per pixel strip
Compression 32767 with BitsPerSample 12 and one strip of exactly width x height x 1.5 bytes is plain packed 12-bit data: two pixels per three bytes, least-significant-bit first (measured on the DSLR-A900 sample by same-colour neighbour smoothness, 12x smoother than the other orders). RGGB per the file's pattern tag (checked against the camera JPEG), black 128, crop = FullImageSize centred in the frame, white balance from the plain maker-note block. Any other strip size stays Unsupported. RENDER_CACHE_VERSION 22 -> 24 (adjust at merge).
2026-10-09 13:57:06 -04:00
radialmonster 7408f11f9a LR-PROF-CAMERACOLOR: partial camera look when the full look misses the gates
A raw whose camera look fails the acceptance gates fell back to the neutral
rendering: dark (9-22 L* below the camera JPEG), about a third of its
colourfulness, hues from the generic camera = sRGB matrix. Before falling
back, fit a partial look: the quantile tone curve (registration-free), the
chroma curve and, on a scene with enough colour, the photo's own matrix at
half weight (no hue/saturation table). It is used only when it cuts the
held-out error to below 0.7 of the neutral fallback's (linear and encoded)
and the JPEG's luminance ranks the pixels like the scene's (Spearman >= 0.5).
The full look's gates and search are unchanged. Render cache version 24.
2026-10-09 13:51:48 -04:00
Vikram Bhaskaran 57b5317722 Export a contact sheet of photos to a paginated PDF.
File → Contact Sheet PDF… writes the current edits to A4 or Letter at 150 dpi, with optional captions, and will not overwrite an original.
2026-10-10 04:43:31 +11:00
MarekSpodBiedry e529e6f8f4 Use the system clock in the CLI and headless MCP sessions (#554) 2026-10-09 19:29:39 +02:00
Nathaniel LedfordandClaude Opus 5.5 e92095a57a LR-BEHAV-TEXTFIELD: Return confirms the one-field dialogs
New Album, Rename Album, New Smart Album, Merge Keywords and the text
prompts (rename a preset or a version, …) had Rename Keyword's bug:
their field took the focus back on every frame, so egui never reported
the focus Return gives up, and Return did nothing; only OK confirmed.
All six now use one `dialog_field`: a shared text field that keeps the
focus except while its own menu has it, opens with its text selected
and reports Return. They get the Cut / Copy / Paste / Select All menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:28:19 -04:00
Nathaniel LedfordandClaude Opus 5.5 9f001c5a53 LR-BEHAV-TEXTFIELD: document the Info panel and Rename Keyword fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 d1ec843863 LR-BEHAV-TEXTFIELD: Rename Keyword's name field is a shared text field
It gets the Cut / Copy / Paste / Select All menu and opens with the
name selected, so typing replaces it. Return now renames: the field
took the focus back on every frame, so egui never reported the focus
Return gives up and only the OK button renamed. The field still keeps
the focus, except while its own menu has it; Esc still cancels the
dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 aa57db05a1 LR-BEHAV-TEXTFIELD: the Info panel's metadata fields are shared text fields
Title, Caption, Alt Text, Extended Description, Copyright, the usage
terms, the creator and the place fields get the Cut / Copy / Paste /
Select All menu. Esc now gives an edit up: it used to save the typed
text, since the field saved whenever it lost the focus. The caption and
other multi-line fields keep taking Return as a new line and save on
leaving; the shared field gains `rows` for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 aec847a2fe LR-BEHAV-TEXTFIELD: CLAUDE.md asks for the shared text field in new text boxes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 3b1e72902a LR-BEHAV-TEXTFIELD: say what Ctrl+V needs and how fields are keyed
What's New and the KEY-CLIPBOARD row overstated the Windows / Linux
fix: Ctrl+V reaches the app only while the system clipboard holds text
(egui-winit drops it otherwise), and it never worked before, so not
"again". The text field's module doc says what it depends on, that its
widget id must be unique on screen, and that new text boxes use it; MCP
`click` lists `button`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 7cc9980eca LR-BEHAV-TEXTFIELD: ui.clipboard needs a focused text field
With no text field focused, `ui.clipboard` (and MCP `clipboard`) sent
the clipboard event to the app's shortcuts: it pasted edit settings on
Windows, Linux and in headless runs, and did nothing on macOS, whose
menu bar takes ⌘V. It is now refused there; agents run `develop.copy`
and `develop.paste` instead. The docs also say that cut and copy write
the system clipboard in the desktop app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 2e23c767b4 KEY-CLIPBOARD: Shift+Insert pastes settings plainly on Windows
egui-winit also sends a paste for Windows' Shift+Insert (and a cut for
Shift+Delete): read with the Shift held, it matched ⇧Ctrl+V and opened
Paste Selected Settings. A clipboard event with no Ctrl/⌘ held is now
the plain command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 a6e59cc5b3 LR-BEHAV-TEXTFIELD: a drag into a select-on-focus field keeps its selection
Select-on-focus selected everything on the press that took the focus,
after egui had placed the drag's anchor there: a drag then selected
from the start of the text. With a press, the field now waits for the
release and selects everything only after a plain click, as a
browser's address bar does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 6fb1a24c88 LR-BEHAV-TEXTFIELD: a field gone with its menu open starts afresh
A field hidden while its menu had the focus (its panel collapsed, its
slider turned off) came back with that state: it reported a spurious
Away when shown again, kept the text from before an edit that was cut
short, and would have replayed a menu choice picked on its last frame.
A field not drawn in the previous frame now starts afresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 72d0c4c319 LR-BEHAV-TEXTFIELD: switching apps carries on with an edit
`Response::has_focus` is false while the window is in the background,
so switching to another app mid-edit forgot the text from before (Esc
then gave back the half-typed text), a select-on-focus field selected
everything again on coming back, and `editing` went false: a slider's
typed value applied and closed. The field now follows egui's own focus,
which the window's doesn't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 17b1bdeeb3 LR-BEHAV-TEXTFIELD: Return and Esc with the field's menu open
Return with the menu open left the edit hanging (the field gave up the
focus, taken for a click in the menu) and it ended later as Away: a
name field that submits on Return never submitted. It now ends the edit
as Return and closes the menu. Esc with the menu open, or with the
focus in the menu, cancelled the whole edit; it now closes only the
menu, as native menus do, and the field takes the focus back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 52e61d6765 LR-BEHAV-TEXTFIELD: a greyed-out menu item does nothing
The field's menu closed on any click, a greyed-out Cut or Copy too, and
the press had already taken the focus: the edit ended as if the user
had clicked away (a slider applied its half-typed value). The menu now
closes only on a choice or a click outside it. The test that said Cut
and Copy need a selection could not fail (egui copies nothing from an
empty selection anyway); it now checks the greyed-out items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 9b78f573ab LR-BEHAV-TEXTFIELD: document the shared text field
A parity row (🟡: three fields use it so far, the rest still use
egui's own) with the summary refreshed, and What's New for the menu,
Esc and Ctrl+C / Ctrl+V on Windows and Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 f9a6b85a83 KEY-CLIPBOARD: Ctrl+C / Ctrl+V copy and paste settings on Windows and Linux
egui-winit turns Ctrl+C / Ctrl+X / Ctrl+V (with any other modifiers)
into clipboard events and drops the key press, so outside text fields
Copy Settings, Paste Settings and ⇧Ctrl+V (Paste Selected Settings)
never fired there; macOS was spared because its menu bar takes ⌘C / ⌘V
first. The shortcuts now read those events as the keys they were, as
the keymap editor already did when recording a shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 a92c38fdd4 LR-BEHAV-TEXTFIELD: the unnamed faces' name field is a shared text field
It gets the Cut / Copy / Paste / Select All menu, and Esc in it now
clears the selection as Clear does, as the code meant: it checked the
field's focus on the Esc frame, which egui has already taken away, so
Esc only left the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 7baf93ecdb LR-BEHAV-TEXTFIELD: a slider's typed value is a shared text field
The value typed after clicking a slider's number gets the Cut / Copy /
Paste / Select All menu (`sliderField:<control>`), and using the menu no
longer closes the field: it used to close as soon as anything took the
focus. Selecting the old value and Esc now come from the shared field;
`TextFieldResponse::editing` says an edit is under way, the menu
included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 4efd92b38f LR-BEHAV-TEXTFIELD: the search field is a shared text field
Search Photos gets the Cut / Copy / Paste / Select All menu, and Esc
gives back the search from before the edit (and filters by it again).
The field gains the builder options it needs (no frame, font, colour,
alignment, margin) and `text_field::id` for ⌘F to focus it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 94ee52088d LR-BEHAV-HEADLESS: ui.clickWidget right-clicks with button "right"
`ui.click` took `button`, `ui.clickWidget` ignored it: MCP `click
{widget, button: "right"}` passes it on, and silently left-clicked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:10 -04:00
Nathaniel LedfordandClaude Opus 5.5 1029e63bde LR-BEHAV-TEXTFIELD: agents cut, copy and paste in text fields
`ui.clipboard {action, text?}` does what the system does for ⌘X / ⌘C /
⌘V in the focused text field (egui-winit sends clipboard events, not
key presses, so `ui.key` with ⌘C could not), and `ui.inspect` reports
`copied`, the text the UI last put on the clipboard. MCP has the same
as the `clipboard` tool when the app is attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:09 -04:00
Nathaniel LedfordandClaude Opus 5.5 50b9660867 LR-BEHAV-TEXTFIELD: a shared text field with a Cut / Copy / Paste menu
`text_field::TextField` is a standalone text field for every text box in
the app:
- right-click for Cut, Copy, Paste and Select All, shortcuts shown,
  acting on the selection the right-click would otherwise move (the
  browser build has no Paste item: a page cannot read the clipboard on
  request);
- Esc gives back the text from before the edit, also after using the
  menu;
- the caller learns how an edit ended (Return, focus away, Esc) and
  decides what that means; using the menu is not leaving the field;
- optionally everything is selected when it takes focus;
- an edit cut short when the field goes away is forgotten.

The menu replays egui's own Cut and Copy events and asks the host for a
paste, so passwords, character limits and undo work as egui does them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:22:11 -04:00
Nathaniel LedfordandClaude Opus 5.5 9311c0516c LR-BEHAV-TEXTFIELD: the headless host has a clipboard
Text the UI copies lands in `HeadlessView::clipboard`, and a cut, copy
or paste request (`ViewportCommand::RequestCut/Copy/Paste`) comes back
as that event on the next frame, as egui-winit does on the desktop. UI
tests can now check copying and pasting in text fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:22:11 -04:00
Brandon ThomasandClaude Opus 5.5 8fd5a6f42f Merge main into LRC-LIB-SMARTCOLL (keep both sides of catalog tests and What's new)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:08:14 -04:00
Brandon ThomasandClaude Opus 5.5 3978a0ac9a French catalog: plurals, paintable separators, percent format, two new strings
- "photo(s){}" printed "photos(s)s"-style text: the plural suffix now follows the noun ("photo{}"),
  as the Spanish catalog does; smart previews repeat it ({0}).
- Menu triangles (▸) in translations replaced by → (the shipped fonts have no ▸ glyph; test
  translated_catalogs_do_not_use_missing_menu_triangles).
- "{:.0} %" -> "{:.0}%" so the Merging progress test matches every language.
- Translate the two strings main added since the branch (Camera local tone mapping).
- README, docs/localization.md and the LR-BEHAV-LOCALE parity row list French.
- Test: French plural formatting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:57:36 -04:00
ROSSAandClaude Opus 5.5 82ff68d4f3 M11.4: raws from 52 camera models start from matrices fitted to measured spectral sensitivities
A raw without colour matrices of its own now gets DNG ColorMatrix1/2 and
ForwardMatrix1/2 for its model when the model is one of the 52 cameras
in rawtoaces-data (Academy Software Foundation, Apache-2.0): 22 Canon,
11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names its
alias list gives (Rebel T3i and Kiss X5 for the EOS 600D; bodies it
lists as sharing a sensor). tools/spectral_camera_table.py fits, under
Standard Illuminant A and D65, a white-preserving 3x3 to the measured
spectral sensitivities over rawtoaces-data's 190 training reflectances
and writes crates/raw/src/spectral_table.rs. No number comes from Adobe
or from a raw decoder. lightcraft_raw::spectral looks a camera up by
Exif make and model; the DNG colour model does the rest.

camera_preview::starting_colour takes the first available of: the
file's own matrices, a camera profile for the model (fitted to the
JPEG for tone), the spectral matrices, the file-local JPEG fit, the
neutral fallback. A profiled model whose profile fit fails takes its
spectral matrices. White balance stays relative to the as-shot look for
every raw without matrices of its own, so Photo::relative_wb and the
controls are unchanged; calibrate still pools covered models.

Tests: one per precedence step (synthetic), the table's lookup, aliases
and DNG shape, and a corpus test of the order on ILCE-7M4 (profile),
EOS 5D Mark III and D5100 (spectral), EOS 7D (own fit) and a DNG. The
ILCE-7RM2 and GFX100S corpus tests now expect the spectral matrices.
Attribution in assets/ATTRIBUTION.md and NOTICE. RENDER_CACHE_VERSION 23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:56:06 -05:00
Alex ChambersandClaude Opus 5.5 5bbb5214f4 LR-BEHAV-BG-TASKS: demo scenes: a rayon worker renders a scene alone, never in parallel (deadlock)
Demo scene pixels were generated inside `OnceLock::get_or_init` on the
caller's thread, and the render runs on rayon. A rayon worker that waits
for rows another worker took runs queued jobs on top of itself; when one
of those asked for the same scene, it waited in `get_or_init` on the
generation further down its own stack, and every other request for that
scene waited behind it, forever. The faces scan's pool threads hit it (two
tests_face_recognize tests sat in `Once::wait` for 30 minutes); any rayon
worker loading demo photos could.

Each (scene, size) slot now has one generation at a time: the first caller
renders, with no lock held, and later callers wait on a condvar for that
generation's outcome. The render never needs a thread that waits:
- a rayon worker (the faces scan, merges) renders on its own thread alone,
  without rayon (`Scene::render_fit_serial`, the same pixels), so it never
  runs other jobs mid-render, and the work stays within its pool and that
  pool's limits (scan pace, LIGHTCRAFT_FACE_THREADS);
- other callers (the render workers, plain threads) render in parallel on a
  pool of rayon's default size (RAYON_NUM_THREADS, else the available
  parallelism), whose threads only render; where it can't start, they
  render alone too (no rayon, so no deadlock). Generating a scene never
  touches the global rayon pool (asking its size would start it, which
  panics where its threads can't start).
The scan's first render of a demo scene now takes 1 to 2.3 s on one
thread instead of 0.5 to 1.3 s on its light pace's two (M3 Max, 2560 px
preview source); interactive loads are unchanged.
A generation number gives each waiter the outcome of the generation it
waited for, even when a retry starts first. A render that panics is an
error for its callers (`scene_pixels` returns a Result) and the next
request tries again. No duplicates, no unbounded threads.

Tests (each waits with a timeout, so a deadlock fails instead of hanging):
rayon workers keep asking a 4-thread pool for a scene while it is generated
(24 replies counted, at least 4 overlapping; times out at 120 s on the
OnceLock code); a scene is rendered once however many ask (the first
version of this fix rendered 5 copies at once); a rayon worker renders
alone on its own thread; callers wait for the generation running; a failed
generation is an error and the next request retries; a retry never changes
the outcome waited for (state-level, and a racing retrier: 60 of 60
waiters got the retry's pixels on the previous version); in a process
whose global pool failed to start, a scene is still generated; the serial
render is the parallel one, pixel for pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:52:10 +02:00
radialmonster 93cc9afe75 Merge remote-tracking branch 'origin/main' into fallback-look 2026-10-09 12:50:00 -04:00
radialmonster 6e37b8ec1a Merge main into reload-lens-data
# Conflicts:
#	crates/catalog/src/tests.rs
2026-10-09 12:49:15 -04:00
Brandon Thomas 8f239d62ec Merge remote-tracking branch 'origin/main' into fix-541 2026-10-09 12:43:18 -04:00
Elliot Graham 92de4c1bbc LR-EDIT-OPTICS-PROFILE: merge main and resolve validation conflicts 2026-10-09 17:37:17 +01:00
Brandon ThomasandClaude Opus 5.5 968646c059 LR-IMP-FORMATS: render cache version 23 (interleaved DNG 1.7 rasters render differently)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:34:53 -04:00
Brandon ThomasandClaude Opus 5.5 af35fa1bd0 Merge origin/main into #513 (parity row keeps main's Hasselblad/SRW notes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:34:53 -04:00
radialmonster cd745e8861 LR-EDIT-OPTICS-PROFILE: Reload from Disk reads a file's lens data and turns the correction on for untouched photos
A catalog written before the lens data was read at import (RW2/RWL from
v0.4.0) has none stored, and the lens correction stays off. Reload now
stores the fresh probe's lens data (new journaled, undoable
Op::SetEmbeddedLens; catalog format 4) and, for photos still at import
settings, switches the correction on as import does. Edited photos and
photos with a default-preset look keep their settings.
2026-10-09 12:34:31 -04:00
Elliot Graham 7343c8f85f LR-EDIT-OPTICS-PROFILE: restrict Sony correction to validated camera 2026-10-09 17:25:14 +01:00
Jason Baldwin 13e3406829 Merge origin/main into #540 (relative-WB format list: SRW from main joins the shared helper) 2026-10-09 18:12:44 +02:00
Nathaniel LedfordandClaude Opus 5.5 9453085cef LR-CROP-STRAIGHTEN: the Angle field writes a turned angle with its sign
The readout and the slider said "+2.50" where the field said "2.50";
it now writes the angle through crop_angle_label too. The test that
said the readout's "0.00°" matched the slider at rest (it shows "0")
says what it does match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 b26bb3fa93 LR-CROP-STRAIGHTEN: document how the Angle field takes an angle
The parity rows cite panels/right.rs, where the field lives, and say
that it applies on Return or leaving it, that Esc keeps the old angle
and that it takes a decimal comma; the Angle slider row points to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 fb0af3aded LR-CROP-STRAIGHTEN: Chinese hover hints use each file's word for click
zh-hans says 单击 and zh-hant 點擊 almost everywhere ("Click to clear
these filters"); the new "Click to type a value" used 点击 and 按一下.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 56ee34fcf8 LR-CROP-STRAIGHTEN: the Angle field reads decimal commas
egui's own parser dropped "-3,25" silently, while the Straighten value
takes it (issue #322). The field now reads a typed angle through the
same `typed_value`: a decimal comma, a trailing degree sign, and an
angle past ±45° held at the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 f34c967900 LR-CROP-STRAIGHTEN: a crop drag cut short leaves nothing behind
Closing the crop tool in the middle of a rotation (Return, Esc) left the
rotate gesture set, as only the canvas's drag-stopped cleared it. Once
the rotation pointer and angle readout were derived from it, reopening
Crop showed both everywhere, over the side panels too, until the next
drag. A crop gesture with no button held is now over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 b83ba84a79 LR-CROP-STRAIGHTEN: a move or resize drag keeps its pointer past the box
The box stops at the photo's edge, so a move or resize drag can carry
the pointer outside it, where the rotation glyph replaced the move or
resize cursor and said the drag rotated. The drag's own cursor now
holds until it ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 d92ec902c8 LR-CROP-STRAIGHTEN: Esc in the Angle field keeps the old angle
Esc applied the typed angle: egui takes the focus away on the Esc frame
and parses the leftover text on the next one, when Esc is no longer
pressed. The field now remembers the Esc for that frame, as the
slider's typed value already keeps the old value on Esc. Leaving the
field another way still applies the angle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 7e109e880f LR-CROP-STRAIGHTEN: the Angle field applies a typed angle on Return
The field applied every keystroke: an undo step and a render per key,
and half-typed angles (-3° on the way to -3.25°). It now applies on
Return or when focus leaves, as one undo step, and ignores changes that
leave the angle as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 676e8c01ce LR-CROP-STRAIGHTEN: keep the angle readout on the canvas, not the photo
The readout was bounded by the photo's rect, so rotating in the margin
beside a narrow photo pinned it to the photo's edge, far from the
pointer (the earlier commit said "kept on the canvas" but used the
photo). It is now bounded by the canvas. Placing it also no longer
panics on NaN or empty bounds (f32::clamp panics on NaN).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 ea1939fb48 LR-CROP-STRAIGHTEN: document the Angle field and the ⌘ crosshair
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 b401b56da4 LR-CROP-STRAIGHTEN: an Angle field in the Crop panel for an exact rotation (#534)
The issue asks for a numeric field for an exact, repeatable rotation.
Typing into the Straighten value worked, but it was hidden behind a
hover hint. The Crop panel now has an Angle field under Straighten (a
drag value in degrees, −45° to 45°, two decimals): click and type, or
drag it. A drag is one undo step, like the slider's
(begin / endInteraction). "Angle" is translated in all seven locales.

Headless tests: typing -3.25 into the field sets exactly -3.25°; a
six-step drag on it adds one undo step (it added five before the
interaction was wrapped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 e88be40e8c LR-CROP-STRAIGHTEN: rotation pointer review fixes: ⌘ crosshair, readout on the canvas, no lag
From review:
- With ⌘ held a drag draws a level line, but outside the crop box the
  pointer showed the rotation arrow (the crosshair was set, then
  overwritten in the same frame). The crop tool now leaves the
  straighten crosshair alone while ⌘ is held and nothing is rotating.
- The angle readout ran past the canvas (and the window) near its
  right or bottom edge. It now flips to the pointer's other side and is
  kept inside the canvas (readout_rect).
- The readout showed the angle from before this frame's rotation. It is
  now drawn after the drag is applied, with the angle just set.

Headless tests: ⌘ held outside the box gives the crosshair and no
glyph; rotating at the canvas's corner keeps the readout on the canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:50 -04:00
Nathaniel LedfordandClaude Opus 5.5 959cfa65f3 LR-CROP-STRAIGHTEN: document the rotation pointer, angle readout and typed angle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:49 -04:00
Nathaniel LedfordandClaude Opus 5.5 44741ecaee LR-CROP-STRAIGHTEN: slider values say they can be typed
Every slider's value already takes a typed number when clicked, which
is how to enter an exact crop angle (#534), but nothing said so.
Hovering a slider value now shows "Click to type a value", translated
in all seven locales. A headless test hovers the Straighten value and
finds the hint painted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:44 -04:00
Nathaniel LedfordandClaude Opus 5.5 3ec6b72871 LR-CROP-STRAIGHTEN: a rotation pointer and an angle readout in the crop tool (#534)
Dragging outside the crop box rotates the photo, but the pointer there
was egui's generic Alias arrow, so nothing showed that a drag would
rotate (issue #534).

- Outside the box, and throughout a rotation drag, the system cursor is
  hidden and a curved double arrow (new code-drawn Icon::RotateDrag) is
  drawn at the pointer on a dark disc, on the tooltip layer
  (`cropRotateCursor`). No system cursor has a rotate shape.
- While rotating, the angle is shown next to the pointer
  (`cropAngleReadout`), formatted like the Straighten value
  (crop_angle_label, reusing widgets::shown_value), every zero as
  "0.00°".
- Headless tests: the cursor outside vs inside the box, the readout
  during a drag and gone after it, the label format, and typing an
  exact angle into the Straighten value (which already worked; now
  guarded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:44 -04:00
Nathaniel LedfordandClaude Opus 5.5 ef31741fbb LR-CROP-STRAIGHTEN: the headless harness reports the cursor and the painted text
For the crop tool's rotation pointer tests (#534): Headless::last_cursor
is the pointer cursor the last frame asked for (what a real host would
show), and painted_text lists every text the last frame painted,
tooltips included. An import test already did the latter inline. Both
let UI tests check what a person sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:44 -04:00
radialmonster 4a0ebddcb2 Merge main into hasselblad-p8 (#448 landed in #546; render cache version 23, as main is at 22)
# Conflicts:
#	docs/parity.md
2026-10-09 12:08:58 -04:00
hxwandClaude Opus 5.5 46c7660e5c #538: breadcrumbs open their folder as spelled; fix browse test on Windows
browse_a_local_folder compared the browsed folder with backslashes replaced
by slashes, but library.browse keeps the platform's separators; compare
paths instead. A breadcrumb now opens the path cut from the original string,
so a UNC share keeps its leading \ and a bare drive opens its root (C:\)
rather than the current folder on that drive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:04:41 -05:00
David Campos 128539a939 masks: improve sky mask edges and horizon handling
Refactor sky masking logic to use rasterize_sky function for improved clarity and maintainability.
2026-10-09 20:04:11 +04:00
radialmonster b4c54dbe54 Merge #448 (hasselblad-3fr-fff-main, with main) into hasselblad-p8 (render cache version 23, as #448 has 22)
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-09 12:03:47 -04:00
David Campos 330f824c31 masking: keep zoom and pan across mask interactions (detail)
Added a function to check if an overlay supports window rendering. Updated overlay handling in various contexts to ensure compatibility with new overlay support logic.
2026-10-09 20:03:42 +04:00
David Campos 57f54868de Update documentation for render state and windows 2026-10-09 20:03:09 +04:00
Jason Baldwin 29b3f35136 LRC-LIB-CATALOG-IMPORT: read Lightroom's Custom white balance by its as-shot shift on relative-WB raws (#510)
A Custom white balance from a Lightroom catalog or XMP sidecar was taken as absolute Kelvin for every
raw. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF; 6500 K
means as shot) then read a small tweak as a large blue shift: ΔE 22 against Lightroom's export on the
photos of issue #510.

The crs mapper now takes a `Target` (preset, rendered, raw with a measured illuminant, relative-WB raw)
instead of a bool. For relative-WB raws, and rendered files with only `Temperature`, a Kelvin value is
converted to the same mired shift from the packet's `AsShotTemperature` / `AsShotTint`. Without that
reference (Lightroom catalogs keep none for Custom photos) the photo stays As Shot and the import
report lists `Temperature, Tint` as not carried over. Import, sidecar read and catalog import decide
the target the same way through `lightcraft_catalog::relative_wb_format`.

Tests: shift from the issue's numbers (3650 → 3578 K lands at ~6275 K), the As Shot fallback with its
report entry, target selection per file, and the catalog mapper's Custom-WB case.
2026-10-09 17:48:05 +02:00
Enzo FLAYAC 877b4fb47f i18n: add French translation catalog and menu options 2026-10-09 17:43:32 +02:00
Alex ChambersandClaude Opus 5.5 2e817a45f4 LRC-DEV-PROCESS: process versions, so a look change can ship without changing existing edits (#146)
Each photo's develop settings record the rendering process they are
interpreted by (`DevelopSettings::process`). There is one process, V1, the
pipeline as it is; nothing renders differently (180 CPU 8/16-bit and GPU
renders of procedural scenes, and the saved settings JSON and hash64, are
byte-identical to main).

Settings saved without the field load as V1 (a field-level serde default),
new settings get the latest (`Default`), and V1 is left out when saving, so
files and preview-cache keys stay as they were. A damaged value (anything
but a whole number up to 2^32 - 1) reads as V1 too, so it never costs the
rest of the settings (an unparsable `lc:settings` sidecar is ignored). Reset moves a photo to the
latest process, section and slider resets don't; virtual copies inherit
it; copy / paste / sync / Auto Sync / presets don't carry it; a number from
a newer LightCraft is kept and renders with the newest known process. The
process is not an edit (Edited badges, import-look checks). The base tone
map is chosen per process in one place (`finish::base_tone`, shared by CPU
and GPU). Photo > Update to Current Process (`develop.updateProcess`) moves
selected photos on an older process to the latest as one undo step. Its
menu enablement follows the selection; a call that names photos (`ids` /
`id`) is validated on those instead, through a new opt-in
`CommandSpec::explicit_targets` (execute checks enablement before the
handler sees `ids`). `develop.get` reports the process. The rule and how to add V2:
docs/process-versions.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:03:45 +02:00
rock-meisterandClaude Opus 5.5 fba436a93b LR-PROF-CAMERACOLOR: match the camera JPEG proxy to the sensor proxy size exactly
proxies() fitted the decoded camera JPEG into the sensor proxy's box,
which keeps the JPEG's own aspect and can round a pixel short: an
ILCE-7RM4's 1616x1080 preview, decoded at 384x257, fits a 192x128
profile proxy as 191x128, and collect_pairs rejected the mismatch. So
`lightcraft-cli calibrate` skipped every file from this camera and no
profile could be written.

Resize the JPEG to exactly the sensor proxy's size instead; proxies()
has already rejected references whose aspect differs by more than 2%.
Where fitting already landed on the proxy's size the result is
identical (fit is resize at a rounded size), so working renders don't
change: 34 real ILCE-7RM4 ARWs render bit-identically. On 923 files
from one trip calibrate goes from 0 to 843 accepted.

Refs #515

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:00:07 -05:00
Nathaniel LedfordandClaude Opus 5.5 b0efba015c LRC-LIB-SMARTCOLL: docs and comments match the code at the end of the branch
From final review: the problem-message example ("rule 2.1: rating is
0–5, not 9") predated the rating check's rewording ("no rating 0–5 is
9"); two doc comments still said Album rules take a plain album; and
What's New described the Album rule's old flat list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 652475d263 LRC-LIB-SMARTCOLL: translate every rule field and group label
From final review: the field menu mixed languages. The new fields (Copy
Name, In a Stack, File Extension, Video Duration, Keyword Count, People
Count, Shutter Speed, Cropped) and four group headings (Keywords &
People, Description, Camera Info, Develop) were English in every
locale. Russian also lacked fifteen older labels (Pick Flag, Any
Searchable Text, Filename, Color Label, Has GPS…).

All are translated, following each locale's existing terms (Stapel /
Pila / スタック / 堆叠 for stacks, Цветовая метка as in Russian's
other colour-label messages). The i18n test now requires every field
and group label in every language, as it does for choice values and
problem texts. pt-br's album search reads "Pesquisar", like its other
searches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 8359aaa04c LRC-LIB-SMARTCOLL: rule editor: calendars only on date fields; in-the-last N as text
From final review:
- Every non-number "between" got date pickers, so a text or choice rule
  written with "between" (by an agent; the check marks it) offered
  calendars that would write dates into it. Only date fields get them
  now; a test checks a "Title between" rule.
- An "in the last" N stored as text ("7") showed as 30, and changing
  only the unit wrote 30. It is now read as the catalog reads it.
- take_escape's comment no longer claims later egui passes see the key
  press (they don't); keying it to the frame just lets it lapse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 e8678bb28e LRC-LIB-SMARTCOLL: rule dialog and sidebar problems cached per change, not per frame
From final review: while the rule dialog was open it ran the rule check
several times, the full query for the live count (sorting every match
only to count them) and rebuilt the album list with a loop walk per
album, on every repaint, including pointer moves over a picker. The
sidebar re-checked every smart album's rules on every frame.

Caches::rules_view keeps the dialog's problems, count and album list
until the catalog revision, the rules, the language or (for "in the
last…" rules) the minute changes. Caches::smart_album_problems keeps
the sidebar's ⚠ problems per catalog revision. Both count their scans,
like the album counts, and a test checks that unchanged frames reuse
them and that rule and catalog changes don't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 a484c66c02 LRC-LIB-SMARTCOLL: album picker: fast with big libraries, keeps the keyboard, scrolls freely
From final review:
- Cost: ancestors and paths were found by scanning every album for each
  row, which grows with the square of the album count (about 1.1 s for
  20,000 albums). An id→position index built once per call or frame
  makes it cost the folder depth. A test lays out and searches 20,000
  albums in nested folders well under its 1 s limit.
- Focus: clicking a folder, or pressing Enter when nothing can be
  picked, took the keyboard from the search box, so typing went
  nowhere. Both hand it back; a headless test types after a folder
  click.
- Scrolling: the highlighted result was scrolled into view every frame,
  fighting the mouse wheel. It now scrolls only when ↑ / ↓ moved to it.
  The headless harness stops rendering once input is handled, so this
  snap-back can't be reproduced there; a test I wrote passed with and
  without the fix and was dropped rather than kept as a false guard.
- Folder rows carry widget info (name, open or not) for assistive
  tools. Dead code (an unused stack) and a test assertion that couldn't
  fail are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 11e36bf060 LR-LIB-CAPTURETIME: a shift past year 9999 (or before 0000) is refused, not skipped
From final review: photo.setCaptureTime quietly skipped photos whose
shifted date would leave years 0000–9999. It reported success, with
the photo missing from `captured`. Such a shift is now refused for the
whole selection with how many photos it concerns, and nothing changes.
A date that doesn't read at all is still skipped, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:58 -04:00
Nathaniel LedfordandClaude Opus 5.5 cf496fa733 LRC-LIB-SMARTCOLL: rule dates accept fractions and time zones after the seconds
From final review: I had refused a time zone or fractions of a second
in rule dates on the premise that capture times are stored local and to
the second. They aren't. Imports write DateTime::to_iso, which carries
EXIF SubSecTime and OffsetTime ("2026-08-14T10:00:00.250+02:00"), so a
rule like "is 2026-08-14T10:00:00.250" did match those photos and was
wrongly refused; saved albums holding such dates would have shown ⚠
too.

rule_date now accepts, after whole seconds, a fraction (1–9 digits)
and a zone (Z or ±hh:mm). Anywhere else ("2026-08-14Z") they are no
date. Issue::DateWithZone and its translations are gone; such values
are NeedsDate. The test matches a photo captured with a fraction and a
zone, and the docs no longer say zones are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 1c5e51faf9 LRC-LIB-SMARTCOLL: loops through a smart album's album filter count too
From final review: loop detection only followed Album rules. A smart
album's own album field (Filter.album, as set by the filter bar or
album.setRules {rules: {album}}) was ignored, so
"album.setRules {id: A, rules: {album: A}}" was accepted and A became
silently empty. On main it would have recursed to a stack overflow; the
guard already stops that.

Filter::albums_tested covers both, and album_reaches follows it.
Catalog::album_filter_problem reports a loop through the album field;
smart_album_problems (sidebar ⚠, albums.list) and album.setRules
(including fromView) use it. A problem with no rule position displays
without the "rule …:" / "#…" prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 0a2ece4491 LRC-LIB-SMARTCOLL: a rule that stopped checking doesn't block other changes
From final review: library.filter and album.setRules (without replace)
merged a change onto the current rules and re-checked all of them.
Once a rule stopped checking later (the album it tests was deleted, a
custom label renamed), every later change was refused: the search box,
keyword clicks and the filter bar silently stopped working, since the
UI doesn't show those errors, and partial edits of the album were
refused.

Only rules a change sets ("ruleSet" present) are checked now. Rules
that went stale still show in the dialog and the sidebar's ⚠ until
they're fixed. A regression test deletes the album a filter rule tests,
then changes the rating, edits the album, and still gets refused for a
bad new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 a51224e6d4 LRC-LIB-SMARTCOLL: smart albums testing smart albums stay linear
From final review: the loop guard bounded the depth of smart albums
testing smart albums, but not repeated work. Each album tested by the
next one twice ("all of: album is X(k-1), album is X(k-1)") doubled the
cost per level, so 25 levels meant 2^25 evaluations per photo, enough
to freeze the sidebar's counts. Any agent or catalog file could build
such a chain.

rules::smart_album_holds remembers each smart album's answer for each
photo during one outermost question, and forgets them when it ends (a
drop guard, however it ends). A regression test counts a 26-deep
doubled chain in well under its 2 s limit; before the fix it ran for
minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 4598d2c186 LRC-LIB-SMARTCOLL: document the album picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 94de255e74 LRC-LIB-SMARTCOLL: a standalone album picker with a tree and search
A flat dropdown doesn't scale to the hundreds of albums and smart
albums a library grows. crates/ui-egui/src/album_picker.rs is a
reusable picker:

- A button opens a popup with a search field, focused on open.
- With nothing typed it shows the sidebar's tree in the sidebar's order
  (entries_from uses Catalog::album_children_by_parent): folders with
  painted disclosure triangles and folder icons, album / smart-album
  icons. Folders are closed except those holding the current choice.
- Typing lists the albums whose "Folder / Name" path holds every word,
  in any case, names starting with the query first. ↑ / ↓ move, Enter
  picks, Esc closes just the popup.
- Albums that can't be picked stay visible but greyed, the reason on
  hover, instead of vanishing.
- Pure, unit-tested helpers: path_label, ancestors, visible_tree,
  search, set_depths. Widget ids albumPicker / albumPickerSearch /
  albumPickerFolder / albumPickerItem.

The rule editor's Album value uses it. The album being edited and
albums that would loop back are blocked with a reason ("This is the
album you're editing", "It tests this album, so testing it back would
loop"). This also removes the stray divider the old dropdown drew above
"Smart Albums" when no regular album preceded it. New strings are
translated in all seven locales, and the strict i18n test lists them;
the unused "Smart Albums" heading is dropped. Headless tests: browsing
the tree, typing with ↑ / Enter, greyed loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 872bff96ea Tests: neutral example names in file-path and long-name fixtures
A file-path rule test and a sidebar long-name test used a real person's
name as their example. They now use "Lake Como Wedding-Day" and keep
what they check: a multi-word folder name, word order that must not
match, and a hyphen a space must not match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 14bff3706a LRC-LIB-SMARTCOLL: correct the docs on smart albums testing smart albums
From review: a loop saved anyway doesn't "match nothing". Inside it, the
album being evaluated counts as empty, so an "isn't" rule on it matches
every photo; the sidebar marks it. Also: Album rules take plain or smart
albums (not "plain" only), the picker lists both, an album that is
smart is no longer a problem, and a broken sentence in the parity row
is mended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 d48f5eaabc LRC-LIB-SMARTCOLL: Update Rules from Current Filter refuses loops
From review: album.setRules with fromView saved the current view
unchecked. Showing smart album A ("Album isn't B") and updating B from
that view made B test itself, which the rule check is meant to refuse.
The view is still saved as it is, but a loop for the album being
updated is refused (Issue::AlbumLoop), and the sidebar's menu item
shows the reason instead of ignoring the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 2ad901e6dc LRC-LIB-SMARTCOLL: album_reaches uses a hash set; loop test describes the guard
From review: the walk through smart-album references kept its seen-set
in a Vec, which is quadratic over long chains. The loop test's comment
now says what a saved loop does: inside it, the album being evaluated
counts as holding no photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 f40c7fb204 LRC-LIB-SMARTCOLL: rule summaries name albums, not ids
Summaries showed an Album rule's id ("album isn't 9"): the sidebar
tooltip, the "Rules:" filter chip and albums.list's rulesText for
agents.

RuleSet::describe_with(&Catalog) and Filter::describe_with name the
album ("album isn't “Excluded Photos”"). One that is gone shows as #9
(rules::album_name). The engine's rulesText and Rules chip use them, and
i18n::rules_label takes the catalog for the translated summaries.
Tests at all three levels: catalog (nested groups, a missing album),
engine (rulesText), UI (German summary).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 9dcfc6faee LRC-LIB-SMARTCOLL: document smart albums testing smart albums
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 5c949e01e8 LRC-LIB-SMARTCOLL: rule editor offers smart albums and blocks loops; Esc closes only popups
- The Album rule's list offers smart albums under a "Smart Albums"
  heading (translated in all seven locales). It leaves out the album
  being edited and any smart album that tests it, since that would loop
  back (album_choices, Catalog::album_reaches).
- The dialog checks rules for the album being edited
  (RuleSet::check_for), so a loop set by other means is marked under its
  row, OK is disabled, and confirm refuses it.
- Esc with any popup open in a dialog (a dropdown, a menu, a date
  picker) closed the dialog with its edits. The dialog now claims Esc
  for an open popup before drawing (widgets::take_escape), so only the
  popup closes. New headless test for a dropdown, next to the
  calendar's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 4e3b15ff6f LRC-LIB-SMARTCOLL: smart albums can test other smart albums (loops refused and survived)
An Album rule may now name a smart album: "Keywords contain travel"
and "Album isn't Excluded Photos" (red or rejected) leaves out whatever
Excluded Photos matches, as its rules change. Smart albums used to be
refused for this.

Loops:
- RuleSet::check_for(cat, owner) refuses an Album rule that would make
  the owner include itself, directly or through other smart albums
  (Issue::AlbumLoop). Catalog::album_reaches walks the references with
  a seen-set; RuleSet::albums_tested lists them. album.setRules checks
  for the album being edited, and smart_album_problems reports a loop
  saved anyway.
- Matching never recurses without end: album_contains evaluates a smart
  album's rules through rules::evaluating, a thread-local stack of
  albums being evaluated with a 32-deep cap. An album already on it (a
  saved loop) holds nothing, and the stack is restored by a drop guard
  however evaluation ends. A test builds A → B → C → A and checks that
  every album returns and reports the loop.

The issue "can't test a smart album" is replaced by the loop issue in
all seven locales. The sidebar counts already refresh while any smart
album uses "in the last…", so an album testing one is covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 034ef489ed LRC-LIB-SMARTCOLL: date picker review fixes: Esc, paging limits, strict parse
From review:
- Esc with the calendar open also closed the rule editor and lost its
  edits. The shortcut layer runs before the UI, and Esc's command (Back)
  closes any dialog. Shortcuts now leave Esc to an open popup (any
  menu or calendar). The picker marks Esc as handled for the frame
  (widgets::take_escape / escape_taken; the mark lasts the whole frame,
  since egui may run more than one pass), and the dialog's own Esc
  check defers to it. New headless test: Esc closes only the calendar.
- Paging wrapped at the ends (‹ on January 0000 showed December 0000).
  step() is now a pure function that stops at years 0000 / 9999; year
  pages are aligned blocks of twelve, and the last one holds 9996–9999.
  View::open clamps a caller's year and month, so a huge `today` can't
  overflow.
- PickedDate::parse reads a time after the day only if it is one
  (T10, T10:00, T10:00:00), as the rule check does; "2026-08-14Tjunk"
  is no longer shown as a valid day.
- Today is marked in the month and year views too (holds_today).
- Documented: ids must be unique among the pickers on screen; the
  picker picks dates, not times (picking a day replaces a typed time).
  The i18n test also covers Year / Month / Day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 8d4e539d02 LRC-LIB-SMARTCOLL: a date with a zone straight after the day is DateWithZone
From review: "2026-08-14Z" (a zone after a date with no time) was
reported as "needs a date" rather than as the time-zone issue it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 04ffa7d6b7 LRC-LIB-SMARTCOLL: document the date picker and the single date reading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 090f761d71 LRC-LIB-SMARTCOLL: date rules get a calendar picker
Each date field in the smart-album rule editor has a DatePicker beside
its text field. It opens on the rule's date at its precision (or on
today, from the session's clock, passed in rules_editor::Env), and
writes what is picked as 2026, 2026-08 or 2026-08-14. Typing still
works, and opening never changes a rule. In a "between", each date's
picker stops at the other, so the two stay in order.

Headless tests pick a day, a month and a year, move between months and
year pages, keep a "between" in order from both ends, and open an
unreadable date on today without rewriting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 1a82be68ea LRC-LIB-SMARTCOLL: a standalone date picker component
crates/ui-egui/src/date_picker.rs: a calendar button that opens a popup
to pick a year, a month or a day. egui_extras' DatePickerButton was
considered and left: its weekday names, month names, "Week", "Cancel"
and "Save" are hard-coded English, it only picks whole days, and it
needs a Save click and the jiff crate.

- PickedDate { year, month?, day? } keeps a value's precision (2026
  means the whole year): parse / iso / precision / first_day /
  last_day. days_in_month, a Monday-first month_grid and in_bounds (any
  day of the value in range) are pure and unit-tested.
- DatePicker::new(id, &mut Option<PickedDate>) with precisions(), min(),
  max() and today(). It opens on the value at its precision (else
  today, on days) and returns a response that is changed() when a date
  is picked. Out-of-range cells are disabled.
- It follows the UI language: Year / Month / Day, month titles and
  labels from the date headings (date_group_label), and weekday headers
  (Mo…Su) translated in all seven locales with "Pick a date". A test
  requires them.
- Widget ids for tests and agents: datePicker:<id>,
  datePickerPrecision:<p>:<id>, datePickerPrev/Next:<id>,
  datePickerCell:<iso>:<id>.
- A code-drawn Calendar icon in icons.rs (original work, covered by its
  attribution entry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 b8d9f82f0e LRC-LIB-SMARTCOLL: one reading of rule dates; refuse time zones
Some date values passed the check but never matched. "2026-10-01 10:00"
(a space, not T) and "…T10:00:00+02:00" (a zone) were accepted, while
the matcher compared them as written against capture times stored as
"2026-10-01T10:00:00".

rules::rule_date is now the one reading for the check and the matcher:
2026, 2026-08, 2026-08-14, then T10 / T10:00 / T10:00:00, with a space
working as the T, and a real date and time (25 o'clock and minute 61 are
refused too). A time zone or fractions of a second can never match a
local, whole-second capture time. They get their own issue,
DateWithZone, translated in all seven locales, instead of matching
nothing silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 633775e5ed LRC-LIB-SMARTCOLL: hints name each field's own operator; Russian operator labels
From review:
- Keywords and People say "are empty" / "aren't empty", but their
  empty-text hint said "is empty". New issues NeedsNamesOrEmpty /
  NeedsNamesOrNotEmpty quote their operators, translated in all seven
  locales with each locale's own label (sind leer, están vacíos, が空…).
- Russian had none of the 20 smart-album operator labels, so its
  editor showed every operator in English and the new hints named
  «пусто», which wasn't on screen. All 20 are translated now, and a
  test requires every operator label in every language.
- Japanese and Chinese join a field and its problem with the full-width
  colon their catalogs use ("タイトル:…").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 f61dc889c6 LRC-LIB-SMARTCOLL: show rule problems in the editor's language
i18n::problem_text shows a problem as the field's label and the issue
in the current language ("Titel: braucht einen Suchbegriff (oder „ist
leer“)"); problem_line puts the rule's position first ("#2 Title: …")
for the sidebar's tooltip. The editor's row notes and the sidebar use
them.

All 20 issue texts are translated in the seven locales, each quoting
its own operator names ("ist leer", «пусто», 「が空」), with あり / なし
for Japanese yes/no. A test requires every issue to be translated in
every language and checks English and German output.

Also corrects the zh-hant sidebar tooltip to name the menu as it is
(編輯智慧型相簿…).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 59fd64ea6f LRC-LIB-SMARTCOLL: rule problems name their field and kind of issue
A Problem carried only an English sentence with the field's id
("`title` needs something to look for…"), which the editor showed
as-is in every language.

Problem now also has `field` (None for a group) and `issue`, an Issue
kind with a short English text for translation catalogs (Issue::text,
Issue::ALL). The English `message` and the `rule 2.1: …` Display stay
as they were for agents and logs. A test covers the field and issue of
each kind of problem and that each issue reads differently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 3cd26848f3 LRC-LIB-SMARTCOLL: mark smart albums with problems in the sidebar
A smart album whose rules no longer check shows ⚠ before its name
(`albumProblem:<id>`). Its tooltip adds "Some rules need fixing (Edit
Smart Album…):" and each problem under the rule summary, in one
tooltip. i18n::problem_text is the one place problems become display
text. The new line is translated in all seven locales.

Also: the album menu's Edit Smart Album… built the dialog itself and
skipped RuleSet::upgrade. It now goes through dialog.smartAlbum like
every other way in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 bb50127de4 LRC-LIB-SMARTCOLL: report saved smart albums whose rules no longer check
Rules are checked when they are made, but a saved smart album can go
bad later. The usual case is a rule testing an album that is then
deleted; it silently matches nothing.

Catalog::smart_album_problems(id) checks a saved album's rules as they
are now (RuleSet::upgrade, then RuleSet::check). It is empty for a sound
smart album, a plain album or no album. albums.list reports `problems`
for every smart album, e.g. ["rule 1: no album 8"].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 31abbcee4f LRC-LIB-SMARTCOLL: rename and rules of a smart album in one step
The rule editor renamed an album (album.rename) and then set its rules
(album.setRules) as two commits. If the second failed (refused rules
before the dialog checked them, or a save error), the album kept the new
name without the rules, and undo needed two steps.

album.setRules takes an optional `name` (blank keeps the album's) and
commits the rename and the rules as one Op::Batch: both or neither, one
undo step. The dialog edits through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 920658c76d LRC-LIB-SMARTCOLL: rule editor never rewrites a rule by drawing it
From review: the number spinner was fixed, but the "in the last" spinner
still clamped a stored N on first draw ("0 days" became 1, hiding the
problem and saving a different rule; 20,000 became 10,000). Values
that weren't an {n, unit} object or a two-item "between" were replaced
by the defaults as soon as they were drawn.

- In the last: shown as stored ({n, unit}, any case, or a plain number
  of days) and written only when the N or the unit is changed, with
  clamp_existing_to_range(false).
- Between: anything but a pair is shown as the default pair but kept
  until one bound is edited, so the check marks it. A slice pattern
  replaces the [0] / [1] indexing.
- Album picker: reads the id as the catalog does; names albums with
  their folder ("Trips / Rome") and sorts them by name.
- Opening an album's rules applies RuleSet::upgrade.
- New tests: drawing leaves five unreadable values as stored and marks
  the three that can't match, and an edit with bad rules doesn't rename
  the album (neither OK nor confirm). The parity note no longer
  overclaims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 850311a884 LRC-LIB-SMARTCOLL: one reading of Album rule ids; folders refused; old album operators upgraded
From review:
- rules::album_rule_id reads an Album rule's id as a whole number,
  written as 3, 3.0 or "3". The check, the matcher and the editor now
  agree; the picker showed a valid "1" as unset.
- A folder holds albums, not photos, so an Album rule naming one matched
  nothing. The check now refuses it ("is a folder; choose an album in
  it").
- Smart albums saved while Album was a number field could use ≥, ≤ or
  between, which the matcher always read as "isn't". Since Album became
  is / isn't only, editing or filtering with them was refused.
  RuleSet::upgrade rewrites them to the "isn't" they meant; merge_filter
  (album.createSmart / setRules) and library.filter apply it before
  checking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 e077172f36 LRC-LIB-SMARTCOLL: document the rule editor's problem marks and album picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 7b7807b499 LRC-LIB-SMARTCOLL: rule editor marks bad rules, blocks OK and picks albums from a list
The dialog showed only the first problem, in place of the match count,
without saying which rule it was about. OK stayed clickable, and an
edit renamed the album before its rules were refused.

- Each rule with a problem gets the message right under its row, in the
  caution colour; inside groups too, and on an empty group's frame. The
  rules_editor::Env carries the problems from RuleSet::check, and each
  row finds its own by path. Markers register `ruleProblem:<salt>`.
- The summary reads "Fix the marked rules to save this album." and OK is
  disabled while any problem remains.
- confirm_dialog refuses problems before anything changes (not even the
  name), and the control channel's ui.dialog.confirm keeps the editor
  open on that error, as the button does.
- Album is its own field kind: is / isn't only (it was a number field
  that offered ≥ and between). It picks its album from a list of the
  plain albums (`ruleAlbum:` / `ruleAlbumItem:<id>:`) and starts with
  none chosen ("choose an album"). album.ruleFields reports kind "album".
- Found while testing: the number editor's spinner clamped a stored
  value outside its range as soon as it was drawn, so "Rating is 9"
  quietly became "Rating is 5". It now uses
  clamp_existing_to_range(false): the range limits dragging, and a
  stored value stays as it is, marked by the check.

New labels (Choose an album…, No albums yet, Fix the marked rules…) are
translated in all seven locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 2d29dde4d0 LRC-LIB-SMARTCOLL: a stale album rule no longer drops the filter bar from the view
From review: view_rules merged the filter bar's settings onto a smart
album's rules through merge_rules, which now also checks the rules, and
fell back to the bare album rules on any problem. A smart album testing
an album that was later deleted therefore lost the filter bar (e.g.
Rating ≥ 3) from Create Smart Album from Filter and the dialog's count,
without a word.

merge_filter does the merge alone; merge_rules is merge_filter plus the
check, for album.createSmart / setRules. view_rules uses merge_filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 3c80695e97 LRC-LIB-SMARTCOLL: rule check and matcher agree on units, ratings and label names
From review of the value checks (false rejections and one silent
mismatch):
- In-the-last units: one table (unit_secs) for check and matcher. Any
  case, singular or plural; no unit means days. An unknown unit is
  refused and now matches nothing, where the matcher used to read it as
  days, so "week" meant one day.
- Rating: refused only when no rating 0–5 could meet the rule, tested
  with the matcher's own comparison ("is 9", "≥ 7", "between 6 and 9").
  "< 6" is broad but valid. Message: "no rating 0–5 is ≥ 7".
- Catalog::label_from_name compares with Unicode lowercase, as rules
  do, so a custom label "Été" is found as "été".
- Empty text hints at the operator that fits: "isn't empty" for
  isn't / doesn't contain, "is empty" otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 b0eba68303 LRC-LIB-SMARTCOLL: document rule value checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 b5fe62e426 LRC-LIB-SMARTCOLL: check every smart-album rule value against its field
RuleSet::problems only checked that a field and operator exist (and,
lately, yes/no values). Any other value was accepted and silently
matched nothing or everything: "Rating ≥ abc", a date of "banana" or
"2026-13", "Color Label isn't pink" (everything), "Title contains ''"
(everything), "in the last 0 days", an album that is gone.

RuleSet::check(&Catalog) -> Vec<Problem> replaces it. Each Problem has
the rule's position (path [1, 0]; shown as "rule 2.1: …"), so the
editor can point at the row. It checks:
- numbers (rating 0–5), shutter speeds (1/250, 2)
- dates: 2026, 2026-08, 2026-08-14 or a full time; real dates only
- between: two bounds; dates in order (2026-08-14 … 2026-08 is fine)
- in the last: N > 0 and a known unit
- choices: the ids, the flag's picked / rejected, a colour label's
  custom name (from the catalog), copyright-status spellings
- text and keyword / people rules: something to look for, or "is empty"
- album: exists and is a plain album
- no empty groups (an empty rule list at the top stays "all photos")

album.createSmart, album.setRules and library.filter report every
problem. The editor's date defaults move from "" (which, as "between",
matched every dated photo) to the current year, and its test now
requires every default to be valid, except text and album rules, which
wait for input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 7ba1470940 LR-LIB-CAPTURETIME: refuse capture-time shifts beyond 10,000 years
Crash fix. photo.setCaptureTime turned `shift` / `hours` into seconds
with a saturating cast and added them with plain `+`. A shift of 1e30
seconds overflowed. Only the last-resort panic guard caught it,
reporting "failed unexpectedly: attempt to add with overflow". A large
but not overflowing shift reached the catalog as a date like
-114079456599-08-23.

A shift that isn't a number or is more than 10,000 years either way is
now refused up front ("the shift is more than 10,000 years") and
nothing changes. The regression test requires that message, so a
caught panic no longer passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00
Nathaniel LedfordandClaude Opus 5.5 a08d0f6b03 LRC-LIB-SMARTCOLL: "in the last N" can't overflow; shift_iso stays in years 0–9999
Crash fix. A huge or negative "in the last N" from an agent or a saved
file ({"n": -1e30}) panicked with "attempt to negate with overflow" in
debug builds. Release builds wrapped instead, and the rule matched the
wrong photos.

- last_secs: a zero, negative, NaN or unreadable N is None (the rule
  matches nothing, either way round). Spans are capped at 10,000 years,
  which means "ever": every dated photo is in it and none is outside it.
- dates::shift_iso: checked_add, and a result outside years 0000–9999
  (which iso_seconds can't read back) is None. It never overflows,
  whatever the seconds.

Regression tests cover both and panicked before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 62aef59178 LRC-LIB-SMARTCOLL: document Megapixels measuring the cropped photo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 9bac3abb4b LRC-LIB-SMARTCOLL: Megapixels rule counts the cropped photo
Megapixels used the original width × height, while Long Edge, Short
Edge and Aspect Ratio measure the photo as shown (oriented and
cropped). One rule set could disagree with itself: a 24 MP photo
cropped to a 4000 × 4000 square had a long edge of 4000 but still
counted as 24 MP. Megapixels now uses Photo::shown_size too, so that
crop is 16 MP. Rotation doesn't change it.

Behaviour change: a smart album with a Megapixels rule can now leave
out heavily cropped photos it used to include.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 5865b4c019 LRC-LIB-SMARTCOLL: localization-ja example uses the new field labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 65516b721c LRC-LIB-SMARTCOLL: Japanese yes/no field labels are plain nouns
With the values now あり / なし, the labels "Has Edits" (編集あり) and
"Has GPS" (GPS情報あり) read "edits: yes … none". They are only
used as smart-album field labels, so they become 編集 and GPS情報 ("編集 …
あり / なし"). The untranslated In a Stack and Cropped become スタック and
切り抜き. A test requires every yes/no field to have a Japanese label
without あり / なし.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 451e3cd28c LRC-LIB-SMARTCOLL: document message contexts for translators
localization.md explains "<context>|<English>" catalog keys and the one
context today (smart album value); localization-ja.md notes あり / なし.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 c42fc7ac24 LRC-LIB-SMARTCOLL: Japanese yes/no rule values read あり / なし
Yes/no rule values used the shared "Yes" / "No" messages, which
Japanese translates as the answers はい / いいえ. As the value of a
condition ("編集あり … いいえ") that reads oddly; あり / なし ("has" /
"has none") is natural.

Messages are keyed by their English text, so the same English word
can't carry two Japanese translations. A small context lookup fixes
that: tr_ctx_in tries "<context>|<source>" first and falls back to the
plain message. Only ja.json adds "smart album value|Yes" / "|No"; the
shared Yes / No (はい / いいえ) stay as they are elsewhere, and every
other language keeps its plain Ja / Sí / Да. A test rejects contextual
keys that name no real value, so a typo can't silently fall back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 fa982a4cc6 LRC-LIB-SMARTCOLL: rule editor keeps an unreadable yes/no value
From review: the editor rewrote a yes/no value that was neither yes nor
no (written by an agent or an older version) to true on every frame, so
the dialog never showed the problem and OK silently saved "Yes",
turning a rule that matched nothing into one that matches. The dropdown
now shows such a value as written and changes it only when the user
picks Yes or No. Until then the dialog reports the problem and OK
refuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 d3730a7024 LRC-LIB-SMARTCOLL: library.filter checks its rule set
From review: album.createSmart and setRules refuse an unknown field, a
missing operator or a yes/no value that is neither yes nor no, but
library.filter applied such a rule set and showed an empty grid. It now
returns the same problems as an error and leaves the current filter in
place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 07d5bc3152 LRC-LIB-SMARTCOLL: yes/no rule values accept 1.0 and 0.0
From review: an agent sending a float (1.0) was refused while 1 was
accepted. Any number equal to 1 or 0 now reads as yes or no; other
numbers (2, 0.5, -1) are still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 b0de6d3fef LRC-LIB-SMARTCOLL: document Yes / No values and the "false" fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 7d3c61c56b LRC-LIB-SMARTCOLL: yes/no rule values read Yes / No and mean what they say
Yes/no fields (Has Edits, Cropped, Has GPS, Virtual Copy, In a Stack,
Best of Similar Shots) showed "true" / "false" in the editor and
summaries, and Russian had no translation for either. Matching read the
value with as_bool().unwrap_or(true), so an agent's "false" (a string)
or any other non-boolean counted as yes, while the summary said
"false".

- catalog: bool_value is the one reading. true / "yes" / "true" / 1 /
  no value mean yes; false / "no" / "false" / 0 mean no (strings in any
  case). Anything else matches nothing, and RuleSet::problems reports it
  ("`edited` is yes or no, not \"maybe\""), so album.createSmart and
  setRules refuse it. bool_label gives "Yes" / "No"; describe() reads
  "has edits is yes".
- ui: i18n::bool_text for the editor's dropdown and translated
  summaries (Ja / Nein…, existing keys in all locales). The editor
  stores the value back as a plain boolean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 4c826246c3 LRC-LIB-SMARTCOLL: own labels for treatment Color and label None
From review: the treatment choice "Color" reused the shared "Color" key,
which zh-hans and ru translate as the colour noun (颜色, Цвет) for the
Color panel, giving "Treatment is colour". The colour-label choice
"None" was masculine in Spanish (Ninguno) next to the feminine
"etiqueta". They are now "In Color" and "No Label", translated in all
seven locales (彩色, Цветное, Sin etiqueta…).

Also moves display_label_gaps_are_reported's doc comment back onto it;
the new choice test had been inserted between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 26df5ab60b LRC-LIB-SMARTCOLL: document readable choice labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 12e1105861 LRC-LIB-SMARTCOLL: readable, translated labels for smart-album choice values
Choice values showed their rule ids in English ("Copyright Status is
publicDomain", "Treatment is monochrome"), and Russian had no
translation for any of them. Kind::Choice now carries (id, label) pairs
like the operators: rules still store and accept the ids, and people
see the labels.

- catalog: choice_label(field, id); RuleSet::describe uses the label
  ("copyright status is public domain").
- engine: album.ruleFields keeps `choices` as ids and adds
  `choiceLabels` {id: label}. A test pins the ids, since the new tuple
  type would otherwise serialize as pairs.
- ui: i18n::choice_text is the one path for the editor's value dropdown
  and translated summaries. Aspect labels are "Landscape (wide)" /
  "Portrait (tall)", because the existing "Landscape" / "Portrait" keys
  mean scenery and people. Seven locales gain Video, Copyrighted, Public
  Domain, Unknown, the aspect labels and Square (ru also Raw). A test
  requires every choice label to be translated in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 6b46c98654 LRC-LIB-SMARTCOLL: stronger rule-editor tests
The field-menu UI test now clicks a group row instead of hovering it,
guarding against the combo popup closing on that click (touch input,
fast clicks). default_values_make_valid_rules now checks each default
value's shape (none, pair, choice, number, a real shutter time) as its
earlier commit message claimed; before, it only checked operators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 c755656bfb LRC-LIB-SMARTCOLL: dot files have no extension; reuse CropGeometry::is_identity
From review: `.hidden` reported the extension "hidden", so it matched
"File Extension is hidden". A name whose only dot leads has no extension
now. Photo::is_cropped uses the existing CropGeometry::is_identity
instead of comparing against a default crop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 5d4eea5f7d LRC-LIB-SMARTCOLL: document grouped rule menu and new fields
parity.md: LRC-LIB-SMARTCOLL lists the 47 fields by menu group and what
Classic has that is still missing. LR-LIB-SMARTALBUM no longer claims
"match-all only, no rule editor". What's New describes the grouped menu
and the new fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 5d86976b82 LRC-LIB-SMARTCOLL: Any Searchable Text covers state, alt text, people, copy name
The any-text rule already searched the city and country but skipped the
state / province. It now also searches the alt text, the people on faces
and a virtual copy's name, so it agrees with the dedicated fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 5fa77aa878 LRC-LIB-SMARTCOLL: file, size, location, description and develop rules
New smart-album fields, each in its menu group:
- Source: Copy Name, In a Stack
- File: File Extension (case and leading dot ignored), Video Duration
- Description: Alt Text
- Location: City, State / Province, Country (Location still matches all)
- Size: Long Edge, Short Edge, Aspect Ratio (landscape / portrait /
  square within 1 %), measured on the photo as shown (oriented and
  cropped)
- Develop: Cropped (crop or straighten), Treatment (color / monochrome)

Photo::extension, Photo::shown_size and Photo::is_cropped hold the
domain rules. A crop rectangle that is NaN or outside the frame is
clamped, so hostile settings can't produce NaN sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 9101757bfb LRC-LIB-SMARTCOLL: Shutter Speed rule
Camera Info gains Shutter Speed, compared in seconds and written as the
camera shows it: "≤ 1/60" finds faster exposures, "between 1/1000 and
1/125" works, and a value that isn't a time matches nothing. The editor
takes it as text (1/250, 0.5, 2").

The shutter parser moves from engine export into the catalog
(parse_shutter_seconds) so export and the rule share it. A new
rules-editor test checks that every field × operator default value
makes a valid rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 e54acbd176 LRC-LIB-SMARTCOLL: Keyword Count, People and People Count rules
Smart albums can test how many keywords a photo has ("2 or more", "2 or
fewer", "exactly 2" via the number operators), who is in it (People: the
names on face regions, with the keyword operators) and how many people.

Photo::keyword_count counts a hierarchical keyword once and ignores case
and blank entries; Photo::people is the domain rule for "people in a
photo" (named face regions, trimmed, case-insensitive, pets and unnamed
faces excluded), now shared by the existing person filter. Keyword and
people rules share one name-list matcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 8c0b70b37f LRC-LIB-SMARTCOLL: field menu with submenus in the rule editor
The rule editor's field dropdown shows Rating, Pick Flag, Color Label and
Any Searchable Text at the top, then a separator and one submenu per
field group instead of one long flat list. The group holding the current
field is shown in bold. Picking a field from a submenu closes the menu
and keeps the operator only when the new field has it. Widget ids
ruleField / ruleFieldGroup / ruleFieldItem let the control channel drive
it, and group names join the i18n display-label gap report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
Nathaniel LedfordandClaude Opus 5.5 adcc2830dd LRC-LIB-SMARTCOLL: group smart-album rule fields for the field menu
FIELDS is now listed in field-menu order, with TOP_LEVEL_FIELDS (rating,
pick flag, colour label, any searchable text) followed by FIELD_GROUPS
(Source, File, Date, Keywords & People, Description, Camera Info,
Location, Size, Develop, Assisted Culling). field_group / field_label
look a field up. album.ruleFields reports each field's group so agents
see the same structure as the editor. Rule ids are unchanged, so saved
smart albums keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:56 -04:00
radialmonster 621dd51cbf LR-IMP-FORMATS: decode Hasselblad lossless JPEG with selection value 8 (FFF, H3D/H4D/CFV-50c 3FR)
Lossless JPEG scans whose SOS gives predictor selection value 8 (which T.81 leaves undefined) are read as Hasselblad codes them (ljpeg::decode_hasselblad): 32-bit little-endian words read from their most significant bit, no byte stuffing; each row in pairs of samples, both Huffman categories before both differences; category 16 is followed by 16 ignored bits and means -32768; each sample is the previous sample of its column parity in the row plus the difference, modulo 2^16, both starting at 32768 on every row. Only the layout measured is accepted (one component, 16 bits, no point transform or restarts, even width); other layouts and selection values above 8 stay Unsupported. The rule was measured on 14 CC0 files with the ljpeg-explorer tool. RENDER_CACHE_VERSION 23.
2026-10-09 10:50:43 -04:00
Alex ChambersandClaude Opus 5.5 0614bd4344 LR-PRE-AMOUNT: presets with the Grain group apply at any amount
scale_patch interpolated every JSON number as a float, including the
integer grain seed; the fractional seed failed to deserialize, merged()
failed and apply_partial returned the photo unchanged, so a preset with
the Grain group (in the default copy groups) did nothing at any amount
other than 100 %. Integer fields are now discrete like strings and
booleans; float settings and integer literals in preset files still
interpolate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:50:05 +02:00
Elliot Graham a6d092ceb0 LR-MASK-BRUSH: separate brush subtraction from erasing (#516) 2026-10-09 15:32:04 +01:00
radialmonster 658accc09e Merge #499 (posterize-tone-fit, with main) into chroma-fit (render cache version 23, as #499 has 22) 2026-10-09 10:20:12 -04:00
Elliot Graham 9d5993e9d1 LR-MASK-OVERLAY: hide overlay when adjusting a mask (#519) 2026-10-09 15:15:37 +01:00
radialmonster 8bf134962e LR-PROF-CAMERACOLOR: docs: clipped sensor pixels left out of the camera look fit, archive numbers 2026-10-09 10:13:29 -04:00
hxwandClaude Opus 5.5 485046bf35 Issue #399: Windows MSI: choose the install folder; upgrades keep it
The interactive installer now walks Welcome -> Destination Folder ->
Ready -> progress -> Completed (from #79). Destination Folder is WiX's
InstallDirDlg: Program Files\LightCraft by default, Change... browses,
and WixUIValidatePath refuses a folder that can't be used.

The chosen folder is written to HKLM\Software\LightCraft\InstallFolder
and read back (into PREVIOUSINSTALLFOLDER, then INSTALLFOLDER after
AppSearch unless given on the command line), so a major upgrade stays in
the user's folder instead of moving back to Program Files. Silent and
passive installs show no dialogs and take INSTALLFOLDER=....

packaging-lint fails if lightcraft.wxs loses the folder page, the
remembered folder or the completion page's launch action.

Tested with wix 5.0.2 (x64, arm64, x86 build) and msiexec /qn: upgrade
from 0.4.0 stays in Program Files; a custom INSTALLFOLDER install, an
upgrade without it (stays), an upgrade with another INSTALLFOLDER (moves
there), and uninstall (files, folder and registry value removed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:50 -05:00
AeRo cfd9c1e20f DNG 1.7 deinterleave - fix per review 2026-10-09 15:57:38 +02:00
Brandon Thomas 45621f5679 Issue #18: Windows MSI: finish dialog with Launch LightCraft
Interactive MSI installs now walk Welcome -> Ready to install -> progress
-> "Completed the LightCraft Setup Wizard" (Finish) with "LightCraft has
been installed" text and a checked-by-default "Launch LightCraft"
checkbox. Failure and cancellation end on WiX's standard FatalError /
UserExit pages; silent (/qn) and basic-UI (/passive) installs show no
dialogs and launch nothing.

- lightcraft.wxs: custom UI set = WixUI_Minimal without the licence page
  (MIT OR Apache-2.0 asks nothing of installers), maintenance dialogs for
  repair/remove, ARPNOMODIFY. Launch uses the Util extension's
  WixUnelevatedShellExec via Explorer (runs as the signed-in user, never
  elevated), Return="ignore", only when NOT Installed and the box is ticked.
- package.ps1: add WixToolset.UI/Util.wixext 5.0.2 to the global cache,
  pass -ext ... -culture en-us, and generate plain side/banner bitmaps
  (brand blue / white) so the MSI carries no WiX sample artwork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 1c5feb7ab3)
2026-10-09 08:48:44 -05:00
Brandon ThomasandClaude Opus 5.5 e25909938e Merge origin/main into #498 (render cache version 22, as main is at 21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:47:34 -04:00
AeRo e253ec5fb3 fix DNG JXL decode 2026-10-09 15:16:59 +02:00
radialmonster cc7bf0e2fd LR-PROF-CAMERACOLOR: fit the camera look without clipped sensor pixels
A clipped raw pixel no longer records the scene's colour (one channel held at
the clip level turns a white sky magenta or blue through the matrix while the
camera JPEG keeps it white). As training pairs such pixels pulled the fitted
colour matrix toward grey and made the chroma curve remove colour at their
display luminance: on the CC0 Z 6II forest sample every chroma sample above
display luminance 0.36 was clipped, and the curve cut colourfulness there to
13-19%.

The proxies now carry a per-pixel clip mask (any covered sensor sample at or
above 0.99, aligned with the CR3 framing). The search on all pixels still
decides whether a photo gets a look and which attempt gives it; only that
attempt is refitted without the clipped pixels, and the look on all pixels is
kept when the refit misses the gates, so no photo loses its look or switches
attempt. Photos without a look get one when the search without the clipped
pixels finds it. Camera-profile pairs leave clipped pixels out too.

Render cache version 20 -> 22.
2026-10-09 09:08:58 -04:00
sanyamsingh001andCodex 265e7e4d50 LR-LIB-RATING: fix relative ratings and grid bracket shortcuts
Co-authored-by: Codex <codex@openai.com>
2026-10-09 22:48:00 +10:00
Elliot Graham 03cb96f86a LR-LIB-FOLDER: delete populated album folders with undo (#501) 2026-10-09 13:33:22 +01:00
sanyamsingh001andCodex 4266b0ad7f LR-LIB-CAPTURETIME: reject impossible anchor dates before shifting photos
Co-authored-by: Codex <codex@openai.com>
2026-10-09 22:23:04 +10:00
Elliot Graham bec1841561 LR-EDIT-DETAIL-DENOISE: avoid shared vector write races (#479) 2026-10-09 13:12:47 +01:00
hxwandClaude Opus 5.5 5630734832 LR-BEHAV-BG-TASKS: render export batches side by side (issue #496)
A batch ran its photos strictly one after another: each render is
row-parallel inside, but a photo's decode, its PNG/TIFF/WebP encode and
the wait on a GPU render are mostly serial, so a 32-core machine averaged
~9.5 busy cores on the CPU path and 1-2 with the GPU.

run_batch now keeps export_parallelism() photos in flight (cores / 4,
1..=4; one on wasm32) on scoped lane threads plus the calling thread.
Each holds an estimate of its working memory from a new export_gate
(twice the memory budget, separate from the decode gate it nests over),
so full-size 24 MP exports (~1.2 GB each) run two at a time by default.
Lanes run at most `lanes` photos past the last one placed, which also
bounds the finished files waiting for their turn. Paths, conflict
handling, original protection, writes, results and progress stay in
batch order (Placer), so the files are the same as one at a time; a
panic in a lane becomes that photo's error.

render_bench's batch scenario now goes through export_batch. 8 x 24 MP
JPEGs, 32 cores + RTX 4080 Laptop: CPU 3.87 s -> 2.35 s, GPU 4.21 s ->
1.82 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:00:59 -05:00
radialmonster 2dea9109d8 LR-IMP-FORMATS: name GoPro GPR (VC-5 DNG) in the unsupported-raw error instead of 'TIFF compression 9' 2026-10-09 07:57:30 -04:00
Elliot Graham 41e51a78ef LR-OPT-PROFILES: decode Sony embedded distortion tables 2026-10-09 12:49:48 +01:00
peiyan0 632a455e34 raw: recognize camera-authored TIFFs without CFA as images (#281) 2026-10-09 17:05:25 +08:00
Adrian D'souza 7d3cd8afe5 XMP: preserve Upright warning modes across import paths 2026-10-09 08:54:49 +05:30
Adrian D'souza ec77f60b1b XMP: surface structured import warnings in UI 2026-10-09 08:49:38 +05:30
Adrian D'souza 20b6eb885e XMP: report unsupported photo import settings 2026-10-09 08:45:25 +05:30
467 changed files with 90934 additions and 5053 deletions

No files matched your search

+3 -3
View File
@@ -26,15 +26,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font build input, so the Japanese font tests run (they skip without it).
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- 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
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
+24 -3
View File
@@ -1,5 +1,7 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
# searches (a few minutes).
name: Packaging lint
on:
@@ -23,7 +25,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -51,6 +53,11 @@ jobs:
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"
@@ -66,3 +73,17 @@ jobs:
EOF
- name: FreeBSD tarball layout (stub binaries)
run: bash packaging/freebsd/package.sh --dry-run
windows-install-folder:
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Install-folder fallback and precedence (real MSI AppSearch)
shell: pwsh
run: |
./packaging/windows/test-install-folder.ps1 -Arch x64
./packaging/windows/test-install-folder.ps1 -Arch x86
+107 -32
View File
@@ -5,8 +5,9 @@
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
# drafts a release.
name: Release
on:
@@ -44,7 +45,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -72,17 +73,17 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -102,7 +103,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -130,23 +131,24 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- 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
@@ -164,7 +166,7 @@ jobs:
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@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -187,9 +189,9 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
@@ -203,8 +205,8 @@ jobs:
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@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -228,7 +230,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -250,8 +252,8 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -265,12 +267,85 @@ jobs:
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@v4
- 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
@@ -282,16 +357,16 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- 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
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -316,7 +391,7 @@ jobs:
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@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
@@ -331,17 +406,17 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install wasm-bindgen CLI
@@ -359,7 +434,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@@ -367,7 +442,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
@@ -378,7 +453,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
+5 -5
View File
@@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
- 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
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@@ -51,7 +51,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: lightcraft-windows-arm64
path: dist/arm64/*
@@ -64,7 +64,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: lightcraft-windows-arm64
path: dist
+12 -6
View File
@@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
+20 -7
View File
@@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
wrong or incomplete, downgrade it to 🟡 with a note.
4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
@@ -55,6 +61,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Pure Rust** in the product. No C/C++ dependencies.
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything
that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind,
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
@@ -76,7 +86,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
@@ -103,6 +113,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
`crates/ui-egui/src/menus.rs`.
- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the
Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id
that is unique on screen. Older fields still on `TextEdit` move over as they are touched.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
Generated
+183 -30
View File
@@ -988,6 +988,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -1062,6 +1072,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1202,6 +1221,19 @@ dependencies = [
"thousands",
]
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1447,6 +1479,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -2230,6 +2283,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2486,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",
]
@@ -2588,6 +2650,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2612,6 +2693,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jixel"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3"
[[package]]
name = "jni"
version = "0.22.4"
@@ -2909,6 +2996,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -2976,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
@@ -2987,18 +3084,26 @@ dependencies = [
"lightcraft-ui-egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"rfd",
"serde_json",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "lightcraft-catalog"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"crc32fast",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-raw",
"log",
"proptest",
"serde",
@@ -3009,10 +3114,11 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"dhat",
"lightcraft-codecs",
"lightcraft-denoise",
"lightcraft-engine",
"lightcraft-mcp",
"lightcraft-merge",
@@ -3024,11 +3130,13 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"avif-serialize",
"bytemuck",
"image",
"image-webp",
"jixel",
"jpeg-decoder",
"jpeg-encoder",
"jxl-oxide",
@@ -3039,6 +3147,7 @@ dependencies = [
"moxcms 0.9.1",
"png",
"proptest",
"rav1e",
"ravif",
"rayon",
"serde",
@@ -3050,7 +3159,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3059,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3076,7 +3185,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3091,7 +3200,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3103,10 +3212,11 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"ab_glyph",
"harfrust",
"jxl-oxide",
"libc",
"lightcraft-catalog",
"lightcraft-codecs",
@@ -3116,8 +3226,11 @@ dependencies = [
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-fetch",
"lightcraft-geocode",
"lightcraft-geom",
"lightcraft-gpu",
"lightcraft-heif",
"lightcraft-lensdb",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
@@ -3139,7 +3252,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3153,7 +3266,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"log",
"rustls",
@@ -3162,9 +3275,17 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "lightcraft-geocode"
version = "0.7.1"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
]
[[package]]
name = "lightcraft-geom"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"proptest",
"serde",
@@ -3172,7 +3293,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3181,6 +3302,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
"log",
"pollster 1.0.1",
@@ -3189,14 +3311,31 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"heic-rs",
"rayon",
]
[[package]]
name = "lightcraft-inpaint"
version = "0.7.1"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.7.1"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
"quick-xml 0.38.4",
]
[[package]]
name = "lightcraft-mcp"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3207,7 +3346,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3223,7 +3362,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3235,12 +3374,13 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"image",
"lightcraft-color",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-inpaint",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
@@ -3252,7 +3392,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3264,7 +3404,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"libc",
"lightcraft-color",
@@ -3276,7 +3416,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3298,7 +3438,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"image",
"lightcraft-color",
@@ -3309,7 +3449,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"candle-core",
"candle-nn",
@@ -3322,11 +3462,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.5.0"
version = "0.7.1"
[[package]]
name = "lightcraft-tiff"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"proptest",
"serde",
@@ -3335,9 +3475,11 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
@@ -3346,6 +3488,7 @@ dependencies = [
"lightcraft-engine",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
"lightcraft-preview",
@@ -3359,7 +3502,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"crc32fast",
"eframe",
@@ -4162,6 +4305,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -6819,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"brotli",
"flate2",
+16 -1
View File
@@ -3,13 +3,14 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
@@ -23,6 +24,8 @@ lightcraft-raw = { path = "crates/raw" }
lightcraft-codecs = { path = "crates/codecs" }
lightcraft-meta = { path = "crates/meta" }
lightcraft-develop = { path = "crates/develop" }
lightcraft-lensdb = { path = "crates/lensdb" }
lightcraft-inpaint = { path = "crates/inpaint" }
lightcraft-scenes = { path = "crates/scenes" }
lightcraft-pipeline = { path = "crates/pipeline" }
lightcraft-gpu = { path = "crates/gpu" }
@@ -35,6 +38,7 @@ lightcraft-mcp = { path = "crates/mcp" }
lightcraft-fetch = { path = "crates/fetch" }
lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
lightcraft-geocode = { path = "crates/geocode" }
# foundation
thiserror = "2"
@@ -66,8 +70,12 @@ tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
avif-serialize = "0.8.6"
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
@@ -84,6 +92,13 @@ eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same)
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] }
# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸
# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it.
zbus = "=5.19.0"
web-time = "1"
# web (apps/lightcraft-web)
+20
View File
@@ -15,6 +15,19 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the
camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data
(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software
Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
crates/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane
fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and
poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache
License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each
ported file says so.
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
@@ -41,3 +54,10 @@ also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/mai
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
licence as OFL-<family>.txt.
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
are never distributed with it. On the user's request (Photo > Download Place Names) the app
downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames
(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed
under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/);
attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own.
+55 -17
View File
@@ -12,6 +12,11 @@
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
@@ -249,6 +254,20 @@ lightcraft --control 7980 ~/Pictures/trip
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
supports cancellation and protects originals and XMP sidecars. From the CLI:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
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.
- **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.
@@ -269,7 +288,8 @@ lightcraft --control 7980 ~/Pictures/trip
- **GPU-accelerated, CPU-exact.** The whole develop pipeline runs as wgpu compute kernels (Metal / Vulkan / DX12),
checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in
~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode.
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects.
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. Batch exports
render several photos side by side, so one's decode and encode overlap another's render.
- **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the
full render follows (~0.2–0.5 s for 24 MP). The next and previous photos are prepared in the background, so stepping
through a shoot takes ~50 ms per photo.
@@ -282,18 +302,21 @@ lightcraft --control 7980 ~/Pictures/trip
## Feature status
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
[docs/target-app-parity.md](docs/target-app-parity.md)):
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
[docs/parity.md](docs/parity.md).
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-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 ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
- **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).
| Area | Status |
|---|---|
@@ -303,12 +326,13 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -316,7 +340,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@@ -325,6 +349,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
## Quick start
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
@@ -352,9 +378,9 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
@@ -383,6 +409,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
@@ -444,7 +473,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
### macOS
@@ -463,6 +494,11 @@ Installers and executables are code-signed.
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `lightcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39 or newer, e.g. Ubuntu 24.04+).
On a Wayland desktop LightCraft runs through XWayland when it is available, because only X11 gives
windows file drag-and-drop; set `LIGHTCRAFT_WAYLAND=1` to use native Wayland (sharper fractional scaling, no drop).
### FreeBSD
| Build | File |
@@ -549,3 +585,5 @@ Forks and modified versions must remove them.
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+567
View File
@@ -0,0 +1,567 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
</p>
<p align="center">
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
</p>
<p align="center">
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">全部 Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的编辑视图:放大视图中是安塞尔·亚当斯的《特顿山脉与蛇河》,右侧打开着 Light 与 Effects 面板,底部的胶片窗格中是四张展示照片" width="100%">
<br>
<sub><i>安塞尔·亚当斯,《特顿山脉与蛇河》(1942)。公有领域,美国国家档案馆。在 LightCraft 中显影。</i></sub>
</p>
> [!NOTE]
> **ArtCraft 是一个由形形色色的创作者组成的社区。** 数字艺术、生成艺术、音乐、游戏 &mdash;
> 只要你在创作,你就是我们中的一员。**[来 Discord 打个招呼吧](https://discord.gg/artcraft)。**
<p align="center">
<a href="#edit-like-you-mean-it">编辑</a> ·
<a href="#color-grading-the-cinematic-way">颜色分级</a> ·
<a href="#before--after">修改前后对比</a> ·
<a href="#masking-that-goes-where-you-point">蒙版</a> ·
<a href="#presets-profiles--the-color-mixer">预设</a> ·
<a href="#organize-everything">图库</a> ·
<a href="#built-for-agents">智能体与 MCP</a> ·
<a href="#fast-native-private">性能</a> ·
<a href="#feature-status">功能状态</a> ·
<a href="#quick-start">快速开始</a> ·
<a href="ROADMAP.md">路线图</a> ·
<a href="#downloads">下载</a> ·
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
<br>
## Edit like you mean it
LightCraft 是一个装在单个原生应用里的完整暗房。每一项调整都是**非破坏性**的,原始文件永远不会被改动。
每个滑块都经过一条**场景参考、宽色域、32 位浮点的管线**渲染:高光像胶片一样柔和滚降,阴影被提亮却不产生光晕,
色彩从拍摄到导出始终保持干净。
<table>
<tr>
<td width="50%" valign="top">
### ☀️ Light
**曝光(Exposure)、对比度、高光、阴影、白色色阶、黑色色阶**,配合边缘感知的局部色调映射(对对数亮度做引导滤波)。
把高光拉到 −100,就能救回一片过曝的天空,而且不会出现用简单曲线时会有的灰色光晕。
### 🎨 Color
按**色温**和**色调**调整**白平衡**(raw 用开尔文,JPEG 用相对值),带预设、自动,以及点击即可中和的**吸管**。
保护肤色的**鲜艳度**、**饱和度**、8 个色段的**颜色混合器**(色相/饱和度/亮度),以及带混合与平衡的
三路**颜色分级**色轮。所有计算都在 OkLCh 这个现代感知色彩空间里完成。
</td>
<td width="50%" valign="top">
### ✨ Effects
让细节更细腻的**纹理(Texture)**、让中间调更有力的**清晰度**、**去朦胧**(暗通道先验加引导滤波细化;
推到负值可以增加空气感)、裁剪后带高光优先、圆度和羽化的**暗角**,以及分辨率无关、可调大小与粗糙度的
胶片**颗粒**。
### 📈 Tone Curve
带可移动分割点的参数化区域曲线,**外加** RGB、红、绿、蓝的点曲线。曲线在构造上就是单调的,
所以不会意外出现影调反转。
</td>
</tr>
</table>
<p align="center">
<img src="docs/images/curve-tetons.jpg" alt="Light 面板下方打开的色调曲线,RGB 点曲线上是一条平缓的 S 形曲线,作用于《特顿山脉与蛇河》" width="100%">
<br>
<sub>RGB 点曲线上一条平缓的 S 形曲线,就在 Light 滑块正下方。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub>
</p>
<br>
## Color grading, the cinematic way
用随处拖动的色轮,分别给阴影、中间调和高光做分离色调。下图中,多萝西娅·兰格的
*《移民母亲》* 只拖动两次就获得了温暖的、类似照片冲印的色调(高光 42°,阴影 28°)。
<p align="center">
<img src="docs/images/grading-migrant-mother.jpg" alt="中间调、阴影和高光的颜色分级色轮,旁边是多萝西娅·兰格的《移民母亲》,被调成温暖的冲印色调" width="100%">
<br>
<sub>Color 面板中的阴影、中间调和高光色轮。<i>多萝西娅·兰格,《移民母亲》(1936)。公有领域,美国国会图书馆。</i></sub>
</p>
<br>
## Before & after
按住 <kbd>\\</kbd> 偷看原图,按 <kbd>Y</kbd> 并排对比,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分屏查看。
下面每一张都是 LightCraft 的真实截图,由智能体通过[控制通道](#built-for-agents)自动截取。
<table>
<tr>
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《特顿山脉与蛇河》的并排修改前后对比:修改后的云层更深,河岸边的阴影更开阔"><br><sub><b>《特顿山脉与蛇河》。</b> 高光 −45、阴影 +38、清晰度 +28、去朦胧 +18。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母亲》的并排修改前后对比:修改后更温暖,阴影被提亮"><br><sub><b>《移民母亲》。</b> 阴影 +42、纹理 +18、分离色调、暗角。<i>多萝西娅·兰格,1936(公有领域)。</i></sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的并排修改前后对比:月球地平线上方是地球,修改后略暖、更浓郁"><br><sub><b>《地出》。</b> 去朦胧 +22、高光 −30、更暖的白平衡、鲜艳度 +22。<i>NASA/比尔·安德斯,阿波罗 8 号,1968(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《蓝色弹珠》的并排修改前后对比:修改后黑色更深,云层细节更结实"><br><sub><b>《蓝色弹珠》。</b> 高光 −38、黑色色阶 −20、去朦胧 +15、鲜艳度 +30。<i>NASA,阿波罗 17 号,1972(公有领域)。</i></sub></td>
</tr>
</table>
<br>
## Masking that goes where you point
用**画笔**涂抹(大小、羽化、流量、密度、擦除),放置带可拖动控制点的**线性渐变**和**径向渐变**,
或者按**亮度范围**、**颜色范围**、**天空**、**主体**和**背景**来选择。用**添加/减去/相交**组合各个组件,
任意反相,每个蒙版还能调 15 项局部调整(色温、色调、曝光、对比度、高光、阴影、白色色阶、黑色色阶、纹理、
清晰度、去朦胧、色相、饱和度、锐化、降噪)以及一个总体的数量。
<p align="center">
<img src="docs/images/masking.jpg" alt="蒙版面板中有一个线性天空蒙版和一个径向阳光光晕蒙版;径向渐变在湖景照片上以红色叠加层画在太阳周围" width="100%">
<br>
<sub>叠加在线性「天空」蒙版之上的径向「阳光光晕」蒙版(色温 +40、曝光 +0.50)。<i>照片来自 LightCraft 程序生成的演示图库。</i></sub>
</p>
<br>
## Presets, profiles & the Color Mixer
内置十八个手工制作的预设(*Golden Hour、Teal & Orange、Faded Matte、Selenium Tone、Crisp
Landscape* 等等),每个都有 0 到 200 % 的**数量**滑块。你可以从任意一组设置保存自己的预设、标记收藏,
并在整个选区之间只复制、粘贴或同步你选定的那些分组。
<p align="center">
<img src="docs/images/presets-mixer.jpg" alt="预设列按 B&amp;W、Color、Film、Landscape、Portrait 和 Style 分组,旁边是 Color 面板,8 色段颜色混合器打开在紫色上" width="100%">
<br>
<sub>预设列与 8 色段颜色混合器并排。<i>照片来自演示图库。</i></sub>
</p>
<table>
<tr>
<td width="50%" valign="top">
### ✂️ Crop, straighten & geometry
自由或锁定的长宽比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖动旋转拉直并始终把最大的裁剪框
保持在画面内,三分线/网格/黄金比例叠加,翻转和 90° 旋转,外加手动垂直、水平、旋转、长宽比、
缩放和位移变换。
</td>
<td width="50%" valign="top">
### ⚫️ Black & White
一键(<kbd>V</kbd>)转为单色,配合 8 色段**黑白混合**压暗天空或让树叶发亮。
之后再用颜色分级调出硒调、棕褐或分离色调的印相效果。
</td>
</tr>
<tr>
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《特顿山脉与蛇河》上的裁剪工具:一个略微旋转的 16:9 裁剪框,带三分线叠加,右侧是几何滑块"><br><sub>一个 16:9 的裁剪框拉直了 +2.5°,带三分线叠加。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的分屏修改前后视图:左边是彩色,右边是带颗粒和暗角的黑白"><br><sub>分屏视图:左边是彩色原图,右边是带清晰度、暗角和颗粒的黑白。<i>演示图库。</i></sub></td>
</tr>
</table>
<br>
## Organize everything
一个不碍事的图库:**所有照片**、**最近添加**、**留用**、**按日期**、嵌套在**文件夹**里的**相册**,
以及**最近删除**。用 <kbd>0</kbd>–<kbd>5</kbd> 评星,用 <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd> 加旗标,用 <kbd>6</kbd>–<kbd>9</kbd> 加颜色标签。搜索支持字段:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每个视图都能按拍摄日期、
导入日期、编辑日期、名称、星级、大小或随机排序(一种稳定的乱序;「视图 → 排序 → 重新乱序」可换一个顺序)。
两端对齐的**照片网格**和**正方形网格**视图都做了虚拟化,
所以无论是四十张还是四万张照片都同样顺滑。
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 张演示照片的两端对齐照片网格,带星级和旗标,侧边栏是 Travel 2026 文件夹下嵌套的相册"><br><sub>照片网格,相册嵌套在文件夹里,带星级和旗标。<i>演示图库。</i></sub></td>
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="正方形网格,显示四张公有领域展示照片及其星级和留用旗标"><br><sub>四张公有领域展示照片的正方形网格。</sub></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的信息面板,显示文件名、2400 × 2400 的 JPEG 尺寸、星级、标题字段和相机元数据"><br><sub>信息面板:文件、尺寸、星级、标题、题注、版权和相机元数据。<i>NASA/比尔·安德斯,《地出》,阿波罗 8 号,1968(公有领域)。</i></sub></td>
</tr>
</table>
<br>
## Built for agents
LightCraft 里的每一个菜单项、滑块、画笔笔触、裁剪手柄和按键都是一个**命令**,拥有稳定的 ID 和
JSON 参数。界面、键盘、CLI、JSON-lines 控制通道和一个 **MCP 服务器**全都通过同一个入口分发。
智能体可以挑片、显影、给天空加蒙版并导出,而且能*看到*结果。
```sh
lightcraft --control 7980 ~/Pictures/trip
```
```jsonc
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 按名称操作任意控件
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
{"method": "ui.screenshot", "params": {"path": "after.png"}}
```
- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、
默认值和当前值。
- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端),
同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着
到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。
```sh
cargo build --release -p lightcraft-cli
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 无界面
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 驱动运行中的应用
```
- **可脚本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
path=out.jpg longEdge=2048` 可以运行任意命令链(无界面、在已保存的图库上,或对正在运行的应用),
每条命令打印一行 JSON 结果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
- **一切皆可撤销**,包括智能体的操作:一次滑块拖动(或脚本里一连串更新)算一步撤销。
- **联系表(原生应用/CLI):** 选中照片,然后 **文件 → 联系表 PDF…**。可选择 A4 或 Letter、横向、
行数和列数以及文件名题注;保存分页 PDF,再用你的 PDF 阅读器打印。导出使用当前编辑,每张照片完整放入
而不再次裁剪,以 150 dpi 和 sRGB 渲染。后台导出支持取消,并保护原文件和 XMP 附属文件。从 CLI 调用:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` 还接受显式的 `ids`、`landscape=true` 和 `paper=letter`。只有当整个 PDF 生成成功后,
它才会替换已有的输出文件。限制:1000 张照片、100 页、256 MiB;题注使用与水印相同的字体覆盖来栅格化
(中日韩文字需要 craft-fonts),过长的名称会在中间省略。
这是一套联系表工作流,
不是打印驱动或自定义拼版编辑器。
- **每个控件都可寻址**(`ui.widgets`)且可按名称点击,所以智能体操作的是真实界面,而不是绕到侧门。
- 本 README 中的截图全部由 [`docs/showcase/`](docs/showcase/) 脚本端到端生成。
协议参考:[docs/control-protocol.md](docs/control-protocol.md)。
<br>
## Fast, native, private
- **纯 Rust,无 C。** 我们自己的 RAW 解码器(DNG、佳能 CR2/CR3、索尼 ARW、尼康 NEF、富士 RAF 含 X-Trans、
松下 RW2/徕卡 RWL、宾得 PEF、奥林巴斯 ORF)、我们自己的色彩科学、我们自己的管线。JPEG、PNG、TIFF、WebP、
PSD 合成图和 JPEG XL 现在就能打开;加 `--features heif` 还能打开 HEIC/HEIF 照片(HEVC 是构建时的选择,
与 PhotoCraft 中一致)。
- **场景参考与宽色域。** 内部使用线性 Rec.2020 浮点、Bradford 适应的白平衡、用色域映射代替裁剪,
raw 走胶片式肩部,未改动过的 JPEG 则像素级直通。
- **分辨率无关的编辑。** 半径和画笔大小都相对于图像,所以 400 px 的预览、你的 5K 显示器和 60 MP 的导出
看起来完全一致。
- **GPU 加速,与 CPU 一致。** 整条显影管线都作为 wgpu 计算内核运行(Metal/Vulkan/DX12),
并与 CPU 管线比对到 1/255 以内。在 24 MP raw 上(Apple M4 Pro):一次滑块更新约 4 ms 重新渲染,
冷启动 2.5 MP 放大视图约 30 ms,全尺寸导出约 0.3 s(含并行 JPEG 编码)。
没有 GPU 时同一条管线在所有 CPU 核心上运行,只重做受滑块影响的那些阶段。批量导出会并排渲染多张照片,
所以一张的解码和编码能与另一张的渲染重叠。
- **即时挑片。** 打开一张 raw 会在约 0.1 s 内显示其内嵌的相机预览或缓存渲染,完整渲染随后跟上
(24 MP 约 0.2–0.5 s)。上一张和下一张会在后台准备好,因此翻阅一次拍摄时每张约 50 ms。
- **后台渲染。** 一个工作线程池在 UI 线程之外渲染放大视图、修改前后对比和每一个可见缩略图:
拖动时用草稿,松手后出全质量。
- **本地优先。** 没有账号、没有云、没有遥测、没有订阅。你的目录是一份只追加的可读操作日志,
可以 diff、备份或重放。
<br>
## Feature status
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
| 领域 | 状态 |
|---|---|
| 图库:相册、文件夹、智能相册、堆叠(含自动堆叠)、虚拟副本、星级、旗标、标签、过滤器栏、搜索、排序、网格、胶片窗格 | ✅ |
| 挑片:比较(同步缩放)和筛选视图、自动前进、即时预览 | ✅ |
| Light、Color、Effects(暗角样式)、色调曲线(+ 细化饱和度、目标调整)、颜色混合器(+ 目标调整)、点颜色、颜色分级、校准、黑白 | ✅ |
| 蒙版:画笔、线性、径向、亮度/颜色范围、添加/减去/相交 | ✅(AI 主体/天空目前使用经典启发式算法) |
| 裁剪、拉直工具 + 自动拉直、翻转、旋转、长宽比、叠加参考线 | ✅ |
| 配置文件(Color、Neutral、Vivid、Landscape、Portrait、Monochrome:我们自己的风格)、预设、版本、历史记录、复制/粘贴/同步设置 | ✅ |
| 相机色彩:DNG 文件使用其自带矩阵 | ✅ DNG · 🟡 我们自己的索尼/富士配置文件;缺少实测校准数据库 |
| 原生 macOS 菜单栏(由命令注册表生成)、控制通道 + 每个控件可寻址、无界面 UI 快照 | ✅ |
| RAW:DNG、CR2、CR3(无损 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未压缩 + 无损/有损压缩)、富士 RAF(未压缩 + 无损/有损压缩,Bayer + X-Trans)、松下 RW2/徕卡 RWL/Panasonic RAW(所有 raw 格式,从 DMC-LX1 到 DC-S1RM2)、宾得 PEF、奥林巴斯 ORF(未压缩)、三星 SRW(未压缩);所有格式的嵌入预览,含 CR3 | 🟡 · CR3、压缩 ORF 和压缩 SRW 解码 ⬜ |
| 细节:锐化、亮度 + 色彩降噪 | ✅ · AI 降噪、超分辨率 ⬜ |
| 污点去除/修复/仿制(自动来源)、可视化斑点、红眼和宠物眼(自动瞳孔检测、眼神光) | ✅ · 内容识别填充、污点图钉编辑 🚧 |
| 导出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸调整、文件大小上限、输出锐化、命名模板、批处理、元数据策略、文字或图像水印 | ✅ |
| HDR:带余量上限的 HDR 编辑、SDR 呈现、可视化 HDR;导出为 ISO 21496-1 增益图 JPEG、PQ AVIF 或 32 位浮点 TIFF | ✅ · HDR 显示 ⬜ |
| 图库持久化(防崩溃的操作日志 + 快照、后台压缩、保存失败会上报)、磁盘缩略图缓存 | ✅ |
| 导入:在原位置添加/复制/移动、重命名与文件夹模板、设备、重复检测、监视文件夹;本地文件夹浏览 | ✅ |
| MCP 服务器(无界面或运行中的应用、持久化图库)、CLI、控制通道 | ✅ |
| XMP 附属文件(读取/写入、自动写入)、读取 `crs:` 显影设置、预设文件(`.lcpreset`、XMP 预设) | ✅ |
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
<br>
## Quick start
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # 打开你的图库(~/Pictures/LightCraft Library;新图库会先带演示照片)
cargo run --release -p lightcraft -- ~/Pictures/trip # 导入你的照片(扫描文件夹,跳过重复项)
cargo run --release -p lightcraft -- --memory # 用完即弃的内存演示会话(不写入任何内容)
cargo run --release -p lightcraft -- --control 7980 # 带自动化通道
cargo xtask web --serve # 在浏览器中运行同一个应用:http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt、clippy、测试、分层、wasm 检查
```
CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一个构建任务
(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建,
而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的
完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
不指定它时,LightCraft 构建和运行的方式完全相同,只是中文和日文文字没有字形。字体从不提交到
本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。
**界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 →
语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。
**日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log`
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),从不写进图库。
从桌面菜单或 Dock 启动没有终端,所以请把这个文件附在错误报告里(**帮助 ▸ 打开日志
文件夹** 会在文件管理器中显示它)。每次启动会把
上一个日志移到 `lightcraft.1.log`,再上一个移到 `lightcraft.2.log`,这样崩溃那次的日志能在下次启动后
保留下来;文件到 16 MiB 停止增长,`--version` 和 `--help` 不写日志,带
`LIGHTCRAFT_NO_PREFS` 运行时只写到标准错误。默认情况下 LightCraft 自己的 crate 记到 `info`,其他都记到
`warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行为和以前一样(LightCraft 自己的 crate 用该级别,其余只记警告和
错误;其他值:只记警告和错误),并且优先于 `RUST_LOG`,后者会把默认值替换为
env_logger 风格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
`*` 结尾的指令覆盖所有以它开头的目标,如 `lightcraft*=debug`)。panic 也会记录在那里,
并且仍然写在临时文件夹里的 `lightcraft-panics.log`。日志器是 `apps/lightcraft/src/logging.rs`。
网页版需要 `wasm32-unknown-unknown` target 和匹配的 `wasm-bindgen` CLI
(`cargo xtask web` 会打印确切的安装命令);见 [docs/web.md](docs/web.md)。
**Nix** 会构建桌面应用和 `lightcraft-cli`(Nix 构建总是包含 craft-fonts 输入,所以日文
文字有字形):
```sh
nix run github:storytold/lightcraft # 桌面应用
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
nix develop github:storytold/lightcraft # rust 工具链 + 原生依赖 + 字体
```
在 flake 配置中(NixOS、home-manager、nix-darwin):
```nix
# flake.nix
inputs.lightcraft.url = "github:storytold/lightcraft";
# 可选:使用你自己的 nixpkgs,而不是 LightCraft 锁定的那个
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
# 然后在 NixOS 或 home-manager 模块中(`inputs` 在作用域内):
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 让 `pkgs.lightcraft` 可用
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
```
`nix build` 会安装与 .deb/.rpm 相同的 desktop 文件、hicolor 图标和 AppStream 元数据,并运行
`cargo test --workspace` 作为它的检查阶段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳过)。
**键盘:** <kbd>G</kbd> 网格 · <kbd>D</kbd> 细节 · <kbd>E</kbd> 编辑 · <kbd>C</kbd> 裁剪 · <kbd>M</kbd> 蒙版 ·
<kbd>Shift</kbd>+<kbd>P</kbd> 预设 · <kbd>\\</kbd> 原图 · <kbd>Y</kbd> 修改前后 · <kbd>Z</kbd> 缩放 ·
<kbd>J</kbd> 溢出警告 · <kbd>⌘Z</kbd> 撤销 · <kbd>⌘/</kbd> 全部快捷键。
## How it's built
一个引擎优先的 Cargo 工作区,由小而经过测试的 crate 组成,并强制分层(`cargo xtask layers`):`geom`、
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 的存在。`mcp` 和应用(`lightcraft`、
`lightcraft-cli`)坐落在 `engine` 之上。
## Contributing
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依据公开规范和黑盒观察来工作。
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
- **纯 Rust**、强制 crate 分层、一切皆命令,并且每次提交前 `cargo xtask ci` 必须通过
(一个提交一个任务 ID)。
- **绝不崩溃。** 非测试代码返回错误而不是 panic:不用 `unwrap()`、`expect()`、`panic!` 或
`unsafe`,对任何来自输入的数据都做受检索引,并且每个崩溃修复都带一个回归测试。细节见
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
有问题、想法,或者想先讨论一个 bug?来 [Discord](https://discord.gg/artcraft)。
<br>
## Downloads
**第一次用 LightCraft?** 从 [getartcraft.com 的 LightCraft 页面](https://getartcraft.com/apps/lightcraft)下载。那是最简单的安装方式。
**想要特定的构建或格式?** 在 GitHub 上,[最新发布](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有构建,[全部发布](https://github.com/storytold/lightcraft/releases)则有更早的版本及其说明。文件名中的 `<ver>` 是版本号,`SHA256SUMS.txt` 列出了每个文件的校验和。
### Windows
| 构建 | 安装程序 | 便携版 |
|---|---|---|
| x64(64 位 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
| arm64(骁龙及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86(32 位) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认
`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
并可选择启动它。无人值守安装:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
### macOS
| 构建 | 文件 | 说明 |
|---|---|---|
| 应用,通用(Apple 芯片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已签名并公证 |
| 命令行工具,通用 | `lightcraft-cli-<ver>-macos-universal.zip` | 已签名并公证 |
### Linux
| 格式 | x86_64 | aarch64 (ARM64) | 说明 |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 随处可运行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 文件)自我更新 |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| 压缩包 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解压到任意位置 |
### FreeBSD
| 构建 | 文件 |
|---|---|
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
### 网页版(WebAssembly)
| 构建 | 文件 | 说明 |
|---|---|---|
| 静态站点 | `lightcraft-web-<ver>.zip` | 在现代浏览器中运行;托管到任意静态服务器 |
<br>
## The Crafting Apps
LightCraft 是 **Crafting Apps** 之一:来自 [ArtCraft](https://getartcraft.com/) 团队的免费开源创作工具,
每一个都用 Rust 从零编写,每一个都能独立使用。
| | 应用 | 用途 | 代码 | 了解更多 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 图像编辑:图层、蒙版、文字和真正的 PSD 文件 | [GitHub](https://github.com/storytold/photocraft) | [网站](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 矢量插画 | [GitHub](https://github.com/storytold/vectorcraft) | [网站](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 视频剪辑、调色与声音 | [GitHub](https://github.com/storytold/filmcraft) | [网站](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片图库与 raw 显影 · 你在这里** | [GitHub](https://github.com/storytold/lightcraft) | [网站](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 阅读、整理和保护 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [网站](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 动态图形与视觉特效 | [GitHub](https://github.com/storytold/effectcraft) | [网站](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 页面排版与出版 | [GitHub](https://github.com/storytold/designcraft) | [网站](https://getartcraft.com/apps/designcraft) |
以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
我们的 Discord 是各种创作者聚集的地方:画画的人、拍照的人、做动画的人、剪片子的人、
排版的人,还有仍在摸索自己喜欢做什么的人。分享你在做的东西,
寻求帮助,告诉我们哪里坏了,或者告诉我们你希望这些工具能做什么。
无论你用什么媒介,无论你做了多久,这里都欢迎你。
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
</p>
<br>
## License and credits
LightCraft 采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任你选择。
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. 必需的声明见 [NOTICE](NOTICE)。
随附的字体、图标、图像和其他资源保留各自的开放许可;每一项都在
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、来源和许可。
展示照片是经 Wikimedia Commons 使用的公有领域作品:安塞尔·亚当斯,*《特顿山脉与蛇河》*
(1942,美国国家档案馆);多萝西娅·兰格,*《移民母亲》*(1936,美国国会图书馆);比尔·安德斯/NASA,
*《地出》*(1968);NASA,*《蓝色弹珠》*(1972)。演示图库由 LightCraft 程序生成。界面字体:
Inter(SIL OFL 1.1)。用 [craft-fonts](https://github.com/storytold/craft-fonts) 构建的版本(所有正式发布版)
还会内嵌它的中文和日文字体(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列在其
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有图标均为原创。
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名称、字标和标志是
ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改的情况下,作为
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支和修改版本必须移除它们。
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
+147 -207
View File
@@ -1,235 +1,175 @@
# LightCraft roadmap
Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
## Where we stand
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
**switch** from Lightroom. Update it when a gap below closes.*
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
[docs/target-app-parity.md](docs/target-app-parity.md).
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
**quality of results and camera coverage**, not missing buttons.
## Headline numbers
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
### By dimension
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
| | Value | Kind |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open |
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
| **Feature breadth** (weighted by use) | **~77%** | estimated |
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
| **Mainstream practitioner** | **~49%** (44–54%) | estimated |
| **Essentials user** | **~64%** (59–69%) | estimated |
| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
## Where we're going
Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
checked on the corpus); then ORF, then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
checked for plausible colour; fix per-model bugs (#85).
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
(references stay in the local `plan/`), then tune against the numbers.
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
Hours are calibrated on this repo's PR history; see
[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated).
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
## By dimension
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
| 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% | 34–58 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) |
## Milestones
## Features
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|---|---|---|---|---|
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
| Scope | Weighted completion | Rows | 2026-10-02 |
| Area | Ready | Breadth | Remaining |
|---|---:|---:|---:|
| P0 (core) | **98.2%** | 200 | 98.7% |
| P1 (important parity) | **95.6%** | 149 | 94.4% |
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
| **All in-scope rows** | **79.7%** | 509 | 75.6% |
| Import and library | 80% | 90% | 25–45 h |
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
| Global develop adjustments | 70% | 93% | 30–60 h |
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
| Masking | 45% | 67% | 60–110 h |
| Remove, heal, Enhance | 30% | 45% | 70–130 h |
| Presets, profiles, versions, sync | 85% | 95% | 8–15 h |
| Export | 75% | 89% | 15–30 h |
| Merge and HDR | 60% | 85% | 20–35 h |
| Views, compare, zoom, culling | 70% | 90% | 15–30 h |
| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h |
| Video | 0% | 0% | 25–45 h |
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
camera coverage, render fidelity), which is why P0 dipped slightly.
Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features).
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
## Languages
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
| Work package | Tracker rows | Agent-hours | Risk |
| Language | Code | Translated | Status |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
| **Total remaining** | | **≈ 270–505** | |
| English | en | 100% | full (source) |
| Simplified Chinese | zh-hans | 87% | partial |
| Spanish | es | 86% | partial |
| Hindi | hi | 0% | none (no Devanagari shaping) |
| Arabic | ar | 0% | none (no RTL / shaping) |
| French | fr | 88% | partial |
| Portuguese (Brazil) | pt-br | 85% | partial |
| Indonesian | id | 0% | none |
| Japanese | ja | 87% | partial |
| German | de | 91% | partial |
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
most of it the raw codecs, lens data, video and HDR).
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
## Upcoming
## Totals
Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md).
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
2. CR3 for every Canon body (25–45 h)
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
4. Full-resolution zoom (8–15 h)
5. AI masks: Subject / Sky / People (40–70 h + model decision)
6. Compressed ORF and HE NEF (28–55 h)
7. Lens profile database of our own (40–80 h)
8. Tests in CI on macOS, Windows and Linux (6–12 h)
9. Per-model raw verification (20–35 h)
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|---|---:|---:|
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
## Progress log
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
- 2026-10-11: 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-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
verified; RAW decoding 76% → 77%; headline numbers unchanged.
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with
the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h);
alpha gate passes.
- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist
81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
(#646), Lightroom-like Auto (#628), activity stack.
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact
full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting
([cr3.md](docs/cr3.md)).
- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays
verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4
profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34.
AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI.
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on
178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in
pure Rust and People view.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import,
folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background
compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment
and tracker rows for camera colour, camera coverage and render fidelity.
- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones
refreshed.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman),
ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0
samples.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
README showcase. ≈ 8 h elapsed.
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
ecosystem) complete.
## Risks that coding hours alone don't retire
## Revision history
- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
then our own calibration; long-tail camera/lens coverage grows over time.
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
Laplacian filters, PatchMatch and HEVC (HEIC).
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
turns it into measured comparisons against local-only Lightroom references.
## Raw format coverage and known gaps
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
## Lightroom Classic catalog migration
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
archived before catalog mutation. Missing originals remain available for relinking.
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
history/snapshots remain source data, and smart collections import current membership. The Lightroom
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
## Log
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
## Chinese and Japanese interfaces, and text watermarks
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); 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 |
| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha |
| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
| 2026-10-02 | major | Parity estimate and effort table |
| 2026-09-30 | major | Created |
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-denoise = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-mcp = { workspace = true }
lightcraft-ui-egui = { workspace = true }
+88 -12
View File
@@ -7,6 +7,7 @@
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
//! lightcraft-cli synth-merge hdr|panorama -o DIR
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
@@ -51,17 +52,19 @@ USAGE:
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
--compact list only the helper tools (every command stays reachable via run_command)
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
same encoder as the app's Export dialog. Options:
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
--settings FILE merge a partial develop-settings JSON file
--preset ID apply a preset (see `commands`/presets.list)
--size N long edge in pixels (default: full size, cropped)
--quality Q JPEG/AVIF quality 1..100 (default 92)
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
--opt format=original (copy + XMP sidecar) --opt metadata=none
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
the file to Apple Photos, into that album)
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
Options:
@@ -85,6 +88,18 @@ USAGE:
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
lightcraft-cli synth-merge hdr|panorama -o DIR
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
lightcraft-cli denoise-serve [OPTIONS]
Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=<this host>:7990 and
LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings,
on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN
address (e.g. the Tailscale IP) or firewall the port to it. Options:
--model ID an installed model other than the selected one
--models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's)
--listen ADDR address:port to listen on (default 127.0.0.1:7990)
--token-env VAR environment variable holding the shared token, ≥ 16 characters
(default LIGHTCRAFT_DENOISE_TOKEN)
--run-on WHERE auto (default), gpu or cpu
--max-connections N concurrent clients (default 8)
lightcraft-cli commands [--json] list every command id with its parameters
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
@@ -115,6 +130,9 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
),
e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
),
e => format!("{dir}: {e}"),
}
}
@@ -145,6 +163,7 @@ fn main() -> ExitCode {
Some("merge") => merge(&args[1..]),
Some("synth-merge") => synth_merge(&args[1..]),
Some("controls") => controls(&args[1..]),
Some("denoise-serve") => denoise_serve(&args[1..]),
Some("calibrate") => calibrate(&args[1..]),
Some("--version" | "-V" | "version") => {
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
@@ -284,10 +303,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()),
@@ -391,7 +414,7 @@ fn merge(args: &[String]) -> Result<(), String> {
}
i += 1;
}
let mut s = Session::new().with_fs().with_default_denoise_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let paths = expand_paths(&files);
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@@ -506,9 +529,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()),
@@ -652,7 +675,7 @@ fn render(args: &[String]) -> Result<(), String> {
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
let mut s = Session::new().with_fs().with_default_denoise_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let abs = expand_paths(std::slice::from_ref(&input));
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
@@ -681,6 +704,8 @@ fn render(args: &[String]) -> Result<(), String> {
o.format = ExportFormat::parse(&ext)
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
}
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
let sidecars: Vec<(String, &Vec<u8>)> =
@@ -695,6 +720,12 @@ fn render(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli: wrote {sc}");
}
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
if let Some(photos) = after.run(&[json!({"path": output})], false) {
if let Some(err) = photos.get("error").and_then(Value::as_str) {
return Err(format!("render: {output} was written, but {err}"));
}
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
}
Ok(())
}
@@ -732,12 +763,12 @@ 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_default_face_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
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_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().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(),
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
};
// 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())?;
@@ -897,3 +928,48 @@ fn controls(args: &[String]) -> Result<(), String> {
}
Ok(())
}
/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`).
#[cfg(feature = "denoise")]
fn denoise_serve(args: &[String]) -> Result<(), String> {
use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve};
use lightcraft_engine::denoise::RunOn;
let mut model: Option<String> = None;
let mut dir: Option<String> = None;
let mut listen = format!("127.0.0.1:{DEFAULT_PORT}");
let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string();
let mut run_on = RunOn::Auto;
let mut max_connections = 8usize;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()),
"--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()),
"--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(),
"--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(),
"--run-on" => {
let v = take_value(args, &mut i, "--run-on")?;
run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?;
}
"--max-connections" => {
max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")?
}
other => return Err(format!("denoise-serve: unknown option `{other}`")),
}
i += 1;
}
let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?;
let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?;
let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?;
eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device);
if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") {
eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)");
}
let opts = ServerOptions { token, max_connections, ..Default::default() };
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
}
#[cfg(not(feature = "denoise"))]
fn denoise_serve(_: &[String]) -> Result<(), String> {
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
}
+130 -5
View File
@@ -7,6 +7,46 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
#[test]
fn contact_sheet_exports_pdf_and_protects_originals() {
let input = tmp("contact-sheet-input.png");
gradient_png(&input);
let out = tmp("contact-sheet.pdf");
let (ok, lines, err) = run_cli(
&[
"--import",
input.to_str().unwrap(),
"export.contactSheet",
&format!("path={}", out.display()),
"paper=letter",
"landscape=true",
"columns=2",
"rows=3",
],
None,
);
assert!(ok, "{err}: {lines:?}");
assert_eq!(lines[0]["result"]["pages"], 1);
assert_eq!(lines[0]["result"]["photos"], 1);
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]"));
let before = std::fs::read(&input).unwrap();
for target in [input.clone(), input.with_extension("xmp")] {
if target != input {
std::fs::write(&target, b"existing sidecar").unwrap();
}
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None);
assert!(!ok, "overwrote protected file");
}
assert_eq!(std::fs::read(&input).unwrap(), before);
assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar");
let prior = std::fs::read(&out).unwrap();
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None);
assert!(!ok);
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
}
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();
@@ -236,11 +276,8 @@ fn snapshot_script_failure_exits_non_zero() {
#[test]
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
let script = tmp("snap-ui-zoom.jsonl");
std::fs::write(
&script,
format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
)
.unwrap();
std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"})))
.unwrap();
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
let o = Command::new(BIN)
@@ -439,6 +476,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]
@@ -540,3 +599,69 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
assert!(image.exists());
}
}
/// `denoise-serve` serves the selected installed model; a client greets it and gets the CPU's answer for a tile
/// (within half-float rounding on the wire), and a wrong token or a missing token variable is refused.
#[cfg(feature = "denoise")]
#[test]
fn denoise_serve_runs_tiles_for_another_computer() {
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_denoise::remote::Remote;
use lightcraft_denoise::run::TileRunner;
let models = tmp("denoise-models");
let _ = std::fs::remove_dir_all(&models);
let dir = models.join("synthetic");
std::fs::create_dir_all(&dir).unwrap();
let manifest = DenoiserManifest {
id: "synthetic".into(),
name: "Synthetic".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: None,
provenance: "made for a test".into(),
domain: Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
};
let onnx = dir.join("model.onnx");
std::fs::write(&onnx, lightcraft_denoise::synthetic::unet_onnx(64, 8, 2, 7)).unwrap();
std::fs::write(dir.join("denoise-model.json"), serde_json::to_vec(&manifest).unwrap()).unwrap();
std::fs::write(models.join("settings.json"), r#"{"model": "synthetic"}"#).unwrap();
let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap();
// without its token the server doesn't start
let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let addr = format!("127.0.0.1:{port}");
let token = "cli-test-token-0123456789";
let mut child = Command::new(BIN)
.args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"])
.env("LIGHTCRAFT_DENOISE_TOKEN", token)
.stderr(Stdio::piped())
.spawn()
.unwrap();
// wait for the banner
let mut err = BufReader::new(child.stderr.take().unwrap());
let mut banner = String::new();
err.read_line(&mut banner).unwrap();
assert!(banner.contains("synthetic 1") && banner.contains(&addr), "{banner}");
let remote = Remote::new(&addr, token, &sha, 64);
let info = remote.hello().unwrap();
assert!(info.device.starts_with("CPU"), "{info:?}");
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
let got = remote.try_run(&input).unwrap().unwrap();
let want = lightcraft_denoise::runtime::CpuRunner::load(&onnx, &manifest).unwrap().run(&input).unwrap();
let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs()));
let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
assert!(worst < 5e-3, "remote vs local: {worst}");
assert!(Remote::new(&addr, "a-wrong-token-000000", &sha, 64).hello().is_err());
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&models);
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
//!
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
//! `docs/web.md`). Differences from the desktop host:
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
+1
View File
@@ -180,6 +180,7 @@ pub fn mime_for(name: &str) -> &'static str {
Some("tif" | "tiff") => "image/tiff",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("jxl") => "image/jxl",
_ => "application/octet-stream",
}
}
+8
View File
@@ -169,6 +169,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
let restore_backend = backend.clone();
Services {
picker: None,
pick_display_profile: None,
screen_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
@@ -213,6 +215,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
// Face models need a folder to live in; the web has none.
pick_model_file: None,
save_preset_file: None,
pick_keyword_list: None,
save_keyword_list: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
write: Some(Box::new(download)),
@@ -224,11 +228,15 @@ 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")?;
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
})),
pick_folder: None,
// the browser reports prefers-color-scheme to egui
system_theme: None,
}
}
+49 -3
View File
@@ -36,6 +36,8 @@ pub struct WireJob {
pub raw: bool,
#[serde(default)]
pub relative_wb: bool,
#[serde(default)]
pub legacy_relative_wb: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
pub lens: Option<EmbeddedLens>,
@@ -62,7 +64,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,
};
@@ -74,6 +76,7 @@ impl WireJob {
max_edge,
raw: job.info.raw,
relative_wb: job.info.relative_wb,
legacy_relative_wb: job.info.legacy_relative_wb,
as_shot_temp: job.info.as_shot_temp,
as_shot_tint: job.info.as_shot_tint,
lens: job.info.lens,
@@ -97,6 +100,7 @@ impl WireJob {
as_shot_tint: self.as_shot_tint,
lens: self.lens,
relative_wb: self.relative_wb,
legacy_relative_wb: self.legacy_relative_wb,
..Default::default()
}
}
@@ -113,6 +117,7 @@ impl WireJob {
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
display: None,
}
}
@@ -183,13 +188,18 @@ impl WorkerCore {
}
};
let info = src.info_or(job.info());
Ok(match &job.stages {
let mut rendered = match &job.stages {
Some(view) => {
let st = self.stages.entry(view.clone()).or_default().clone();
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
}
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
})
};
// an HDR edit's histogram shows its HDR range (as on native)
if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) {
rendered.histogram = h;
}
Ok(rendered)
}
}
@@ -331,11 +341,46 @@ impl CacheWatch {
}
}
/// Hands out the positions that render workers will have in the main thread's worker list.
/// A worker that fails to start gets no position, so the position a callback captures stays the
/// index of its own worker in that list (#229).
#[derive(Debug, Default)]
pub struct WorkerSlots {
next: usize,
}
impl WorkerSlots {
/// The position the worker being started will have, if it comes up.
pub fn peek(&self) -> usize {
self.next
}
/// The worker at [`Self::peek`] came up and was added to the list.
pub fn commit(&mut self) {
self.next += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_engine::Session;
// Issue #229: a failed worker attempt must not shift the positions of the workers after it
#[test]
fn a_failed_worker_attempt_leaves_no_gap_in_the_slots() {
let mut slots = WorkerSlots::default();
let mut taken = Vec::new();
for attempt_ok in [false, true, true, true] {
let i = slots.peek();
if attempt_ok {
taken.push(i);
slots.commit();
}
}
assert_eq!(taken, vec![0, 1, 2]);
}
#[test]
fn clearing_index_changes_disk_namespace_and_survives_restart() {
let key = lightcraft_preview::hash_bytes(b"thumbnail");
@@ -420,6 +465,7 @@ mod tests {
max_edge: 512,
raw: false,
relative_wb: false,
legacy_relative_wb: false,
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
+22 -16
View File
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::backend::Backend;
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, WorkerSlots, thumb_storage_key};
/// Storage key of the thumbnail index.
pub const THUMB_INDEX: &str = "thumbs/index.json";
@@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
Ok((r, stored)) => {
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
let h = &r.histogram;
let hist = Uint32Array::new_with_length(4 * 256 + 1);
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
}
hist.set_index(1024, h.total);
let hist = Uint32Array::from(h.to_words().as_slice());
set(&out, "ok", &true.into());
set(&out, "w", &(r.image.width as u32).into());
set(&out, "h", &(r.image.height as u32).into());
@@ -245,14 +241,17 @@ impl Workers {
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
return w;
}
for i in 0..n {
let mut slots = WorkerSlots::default();
for attempt in 0..n {
// The callbacks name their worker by its place in `workers`; a failed attempt takes none.
let i = slots.peek();
let opts = web_sys::WorkerOptions::new();
opts.set_type(web_sys::WorkerType::Module);
opts.set_name(&format!("lightcraft-render-{i}"));
opts.set_name(&format!("lightcraft-render-{attempt}"));
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
Ok(x) => x,
Err(e) => {
log::warn!("render worker {i}: {e:?}");
log::warn!("render worker {attempt}: {e:?}");
continue;
}
};
@@ -268,10 +267,15 @@ impl Workers {
set(&init, "module", &module);
set(&init, "store", &store.into());
if let Err(e) = worker.post_message(&init) {
log::warn!("render worker {i}: {e:?}");
log::warn!("render worker {attempt}: {e:?}");
// It never joins the list, so it must not answer to the index of the next worker.
worker.set_onmessage(None);
worker.set_onerror(None);
worker.terminate();
continue;
}
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
slots.commit();
}
w
}
@@ -291,8 +295,10 @@ impl Workers {
level: b.level,
key: b.key,
rendered: Err(msg),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
};
g.done.push((b.slot, r, 0.0));
}
@@ -316,12 +322,8 @@ impl Workers {
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
match Rgba8::from_bytes(wd, ht, &rgba) {
Some(image) if hist.len() == 1025 => {
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
Ok(Rendered { image, histogram, deep: None })
}
match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {
(Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),
_ => Err("render worker: bad image".to_string()),
}
} else {
@@ -377,8 +379,10 @@ impl Workers {
level: b.level,
key: b.key,
rendered,
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
},
ms,
));
@@ -429,8 +433,10 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
rendered: Ok(Rendered { image, histogram, deep: None }),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
};
g.done.push((slot, r, 0.0));
return None;
+12 -1
View File
@@ -23,17 +23,28 @@ lightcraft-sysmem = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
# probing the window's renderer before it opens (`window_gpu`)
pollster = { workspace = true }
log = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { workspace = true }
objc2 = { workspace = true }
objc2-app-kit = { workspace = true }
objc2-foundation = { workspace = true }
+198
View File
@@ -0,0 +1,198 @@
//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux
//! desktops whose windowing layer reports no theme to egui (many Wayland compositors).
//!
//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker
//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a
//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer.
//! Elsewhere egui's own report (`Context::system_theme`) is used.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
/// another variant inside it.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Wake the first frame after a slow initial Read as well as subsequent real changes.
#[cfg(target_os = "linux")]
fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock<egui::Context>, code: u8) {
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
&& let Some(ctx) = wake.get()
{
ctx.request_repaint();
}
}
/// The host's system-appearance service (Linux), or `None` where egui's report is enough.
pub fn service() -> Option<lightcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, Ordering},
};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("appearance-portal".into())
.spawn(move || {
let proxy = portal_proxy();
// Subscribe before Read so a change during startup is queued rather than lost.
let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
publish(&worker_value, &worker_wake, code_of(initial));
let _ = ready.send(());
let Some(signals) = signals else { return };
// Blocks until the portal sends a signal: no polling.
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
publish(&worker_value, &worker_wake, code);
}
})
.is_err()
{
return None;
}
// The first frame shows the right theme when the portal answers quickly; a slow one
// only delays the switch (the worker wakes the UI when the value changes).
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn delayed_initial_read_repaints_only_on_change() {
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, AtomicUsize, Ordering},
};
let ctx = egui::Context::default();
let settle = |ctx: &egui::Context| {
for _ in 0..8 {
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
}
};
settle(&ctx);
let calls = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&calls);
ctx.set_request_repaint_callback(move |_| {
observed.fetch_add(1, Ordering::Relaxed);
});
let wake = OnceLock::new();
assert!(wake.set(ctx.clone()).is_ok());
let value = AtomicU8::new(0);
super::publish(&value, &wake, 2);
assert_eq!(value.load(Ordering::Relaxed), 2);
let after_change = calls.load(Ordering::Relaxed);
assert!(after_change > 0);
settle(&ctx);
let after_settle = calls.load(Ordering::Relaxed);
super::publish(&value, &wake, 2);
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_values_and_gsettings_output_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
let plain = zbus::zvariant::OwnedValue::from(1u32);
assert_eq!(super::portal_code(plain), Some(1));
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
assert_eq!(super::portal_code(wrong), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::parse_gtk_scheme(""), None);
for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] {
assert_eq!(super::decode(u32::from(super::code_of(theme))), theme);
}
}
}
+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();
+296
View File
@@ -0,0 +1,296 @@
//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it,
//! or any other application the user named in Settings ▸ External Editing.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`).
const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT;
/// PhotoCraft's application id (Flatpak, desktop entry).
const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft";
/// How an editor is started.
#[derive(Debug, PartialEq)]
enum Launcher {
/// The system's default application for the file.
Default,
/// A program file, given the photo as its argument.
Program(PathBuf),
/// macOS: `open -a <app>` (an application name or bundle path).
MacApp(String),
/// The PhotoCraft Flatpak, with the file forwarded into its sandbox.
Flatpak,
}
/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default):
/// for PhotoCraft, that it is installed. The error says what to do instead.
pub fn check(app: &str) -> Result<(), String> {
launcher(app).map(|_| ())
}
/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside.
pub fn open_with(path: &str, app: &str) -> Result<(), String> {
let mut c = match launcher(app)? {
Launcher::Default if cfg!(target_os = "macos") => {
let mut c = Command::new("open");
c.arg(path);
c
}
Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path),
Launcher::Default => {
let mut c = Command::new("xdg-open");
c.arg(path);
c
}
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app, path]);
c
}
Launcher::Program(p) => {
let mut c = Command::new(p);
c.arg(path);
c
}
Launcher::Flatpak => {
let mut c = Command::new("flatpak");
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]);
c
}
};
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// 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).
fn windows_default_command(path: &str) -> Command {
let mut c = Command::new("explorer.exe");
c.arg(path.replace('/', "\\"));
c
}
fn launcher(app: &str) -> Result<Launcher, String> {
let app = app.trim();
if app.is_empty() {
return Ok(Launcher::Default);
}
if !app.eq_ignore_ascii_case(PHOTOCRAFT) {
return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) });
}
find_photocraft().ok_or_else(|| {
"PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string()
})
}
fn find_photocraft() -> Option<Launcher> {
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string());
if cfg!(target_os = "macos") {
// LaunchServices finds the app wherever it is; these are only the usual places
let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app"));
let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string())));
}
if cfg!(target_os = "windows") {
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None);
return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program);
}
// Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which
// registers one on first run), else the Flatpak
let on_path =
env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
let from_entry = || {
desktop_entry_dirs(env).into_iter().find_map(|d| {
let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?;
desktop_exec(&text).filter(|p| p.is_file())
})
};
if let Some(p) = on_path.or_else(from_entry) {
return Some(Launcher::Program(p));
}
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
flatpak.then_some(Launcher::Flatpak)
}
/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI
/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default
/// folders (per machine, then per user).
fn windows_candidates(install_dir: Option<String>, app_path: Option<String>, env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe")));
out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"'))));
for var in ["ProgramW6432", "ProgramFiles"] {
out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe")));
}
out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe")));
out
}
/// A string value from the Windows registry (`reg query`; `None` = the key's default value).
fn reg_query(key: &str, name: Option<&str>) -> Option<String> {
let mut c = Command::new("reg");
c.args(["query", key]);
match name {
Some(n) => c.args(["/v", n]),
None => c.arg("/ve"),
};
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
c.creation_flags(CREATE_NO_WINDOW);
}
let out = c.output().ok().filter(|o| o.status.success())?;
reg_value(&String::from_utf8_lossy(&out.stdout))
}
/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line.
fn reg_value(output: &str) -> Option<String> {
output.lines().find_map(|l| {
let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?;
Some(v.trim().to_string()).filter(|v| !v.is_empty())
})
}
/// The folders desktop entries live in (XDG base directories).
fn desktop_entry_dirs(env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
match env("XDG_DATA_HOME") {
Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)),
_ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))),
}
let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from));
dirs.into_iter().map(|d| d.join("applications")).collect()
}
/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped).
fn desktop_exec(entry: &str) -> Option<PathBuf> {
let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim();
let program = match exec.strip_prefix('"') {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH. 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)]
mod tests {
use super::*;
/// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not
/// through a shell.
#[test]
fn windows_default_open_goes_through_explorer_not_cmd() {
let path = r"C:/Edits/a & b ^c %PATH% | d.psd";
let c = windows_default_command(path);
assert_eq!(c.get_program(), "explorer.exe");
let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
}
/// 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 {
"ProgramFiles" => Some(r"C:\Program Files".to_string()),
"LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()),
_ => None,
};
let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env);
let c: Vec<String> = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect();
assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}");
assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped");
assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}");
assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}");
assert_eq!(c.len(), 4);
}
#[test]
fn reg_query_output_is_read() {
let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n";
assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\"));
let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n";
assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe"));
assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None);
}
#[test]
fn desktop_entries_give_their_program() {
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage")));
assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft")));
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup");
assert_eq!(desktop_exec("[Desktop Entry]\n"), None);
let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string()));
assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications"));
assert!(dirs.contains(&PathBuf::from("/usr/share/applications")));
}
#[test]
fn other_editors_are_used_as_named() {
assert_eq!(launcher("").unwrap(), Launcher::Default);
assert_eq!(launcher(" ").unwrap(), Launcher::Default);
let named = launcher("/usr/bin/gimp").unwrap();
if cfg!(target_os = "macos") {
assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into()));
} else {
assert_eq!(named, Launcher::Program("/usr/bin/gimp".into()));
}
}
}
+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);
}
}
+371 -107
View File
@@ -23,11 +23,19 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
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;
#[cfg(target_os = "macos")]
mod screen_profile;
mod window_gpu;
use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
@@ -59,7 +67,7 @@ impl eframe::App for App {
// Quit from the menu bar: save exactly what closing the window saves (settings and
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
// process instead of closing the viewport while AppKit is terminating (PR #444).
eframe::App::on_exit(self);
eframe::App::on_exit(self, None);
std::process::exit(0);
}
self.0.logic(ctx);
@@ -71,27 +79,48 @@ impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self) {
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
if let Err(e) = self.1.save(&self.0) {
log::error!("{e}");
}
if let Err(e) = self.0.session.close_library() {
log::error!("saving the library failed: {e}");
}
// last, with everything saved: no render may be inside the GPU driver when the process ends
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);
}
}
/// How long quitting waits for renders that are running (issue #620). A GPU render takes well under
/// a second; anything slower gives way to quitting.
const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
#[cfg(test)]
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
}
c
window_gpu::base_options()
}
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
/// and `LIGHTCRAFT_NO_PREFS`.
fn window_files(in_memory: bool) -> window_gpu::Files {
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
}
/// Written while the GPU compute device is created, removed once that returned
@@ -133,6 +162,23 @@ fn gpu_crash_notice(what: &str) -> String {
)
}
/// 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> {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
let candidates: Vec<Option<std::path::PathBuf>> = if cfg!(target_os = "macos") {
vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
} else if cfg!(windows) {
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
} else {
let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
};
candidates.into_iter().flatten().find(|d| d.is_dir())
}
fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
}
@@ -158,6 +204,23 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => workspace.validate().is_err(),
Ok(None) => false,
Err(_) => true,
});
if recovered {
fields.remove("docking");
}
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
Ok((ui, recovered))
}
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
@@ -167,8 +230,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
return (None, Some(msg), true);
}
};
match serde_json::from_slice::<UiState>(&bytes) {
Ok(ui) => (Some(ui.sanitized()), None, false),
match decode_saved_ui(&bytes) {
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
Ok((ui, true)) => {
let msg = format!(
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
path.display()
);
(Some(ui.sanitized()), Some(msg), true)
}
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
(None, Some(msg), true)
@@ -245,6 +315,9 @@ impl PrefsWriter {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
workspace.validate()?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
@@ -323,9 +396,27 @@ fn explorer_select_command(path: &str) -> std::process::Command {
c
}
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
/// for Help ▸ Open Log Folder.
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
/// A file dialog owned by the app's window, or a plain one before the window exists.
fn file_dialog() -> rfd::FileDialog {
DIALOG_PARENT.get().cloned().unwrap_or_default()
}
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
let ctx = cc.egui_ctx.clone();
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
Services {
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
// stopped the window answering the compositor, which then reported the app as hung.
@@ -336,7 +427,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
let mut d = rfd::FileDialog::new();
let mut d = file_dialog();
if let Some(t) = &req.title {
d = d.set_title(t);
}
@@ -362,37 +453,18 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
})),
log_file: log_file.map(|p| p.display().to_string()),
pick_lightroom_catalog: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_folder: Some(Box::new(|| {
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
let mut c = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
if !app.is_empty() {
c.args(["-a", app]);
}
c
} else if app.is_empty() {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
} else {
std::process::Command::new("xdg-open")
}
} else {
std::process::Command::new(app)
};
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
open_with: Some(Box::new(editors::open_with)),
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://") {
return Err("only https links are opened".into());
@@ -420,7 +492,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
})),
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
@@ -429,14 +501,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.collect()
})),
pick_denoise_model: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default()
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
@@ -449,7 +521,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.collect()
})),
pick_model_file: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
.pick_file()
@@ -457,23 +529,56 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.unwrap_or_default()
})),
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
// 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()
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
if let Some(dir) = display_profile_dir() {
d = d.set_directory(dir);
}
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
})),
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_keyword_list: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
@@ -483,7 +588,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.collect()
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
@@ -499,6 +604,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
system_theme: appearance::service(),
}
}
@@ -506,6 +612,8 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(s, problem)
}
@@ -558,6 +666,7 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
@@ -613,10 +722,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
fn main() -> eframe::Result {
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
if let Some(logger) = logger {
logging::record_panics(logger);
}
@@ -687,7 +802,14 @@ fn main() -> eframe::Result {
let gpu_crash = gpu_crash_check(in_memory);
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
lightcraft_engine::gpu::set_enabled(gpu_on);
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
// build egui's pipeline never kills the start-up
let win_files = std::sync::Arc::new(window_files(in_memory));
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
win_files.arm(window_renderer);
let win_files_app = win_files.clone();
let options = eframe::NativeOptions {
renderer,
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
.with_inner_size([1600.0, 1000.0])
@@ -699,73 +821,100 @@ fn main() -> eframe::Result {
.with_icon(app_icon())
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
.with_app_id(APP_ID),
wgpu_options: window_wgpu_options(),
wgpu_options,
..Default::default()
};
// the app's copy: `log_file` is still named in the message of a start that fails
let app_log_file = log_file.clone();
let started = eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
session.segmenter.dir =
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; LIGHTCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("LIGHTCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
// the window's renderer is up (eframe calls this after creating it)
win_files_app.started(window_renderer);
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session = session.with_default_sam3_model();
// 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;
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
#[cfg(target_os = "macos")]
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
app.notices.extend(launch_notices);
app.notices.extend(window_notice);
app.gpu_notice = true;
// what's on disk now: only changes are written
let writer = PrefsWriter {
path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
..Default::default()
};
// after the snapshot above, so the writer saves the switched-off preference
if let Some(notice) = gpu_crash {
app.ui.settings.gpu = false;
app.notices.push(notice);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if let Some(mut p) = problem {
// imported once the user has chosen where (into the library, or the temporary session)
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
// listed and read on the import worker once the window shows (issue #374: never
// here, before the first frame, where a large folder kept the window from
// appearing)
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
log::warn!("import at launch: {e}");
}
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
#[cfg(target_os = "macos")]
menu,
)))
}),
);
let menu =
(std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
writer,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}));
// a panic that still escaped the renderer's start (the probe and error handler should
// prevent it): an error like any other failed start
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
if let Err(e) = &started {
// the renderer never came up: start again, which tries the next one in the chain
if window_gpu::relaunch_after_failure(&win_files) {
log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name());
return Ok(());
}
startup_failed(&e.to_string(), log_file.as_deref());
}
started
@@ -818,6 +967,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&home);
}
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
/// adds to Apple Photos on a worker (headless sessions wait instead).
#[test]
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
let (s, problem) = open_session(true, None, false);
assert!(problem.is_none());
assert!(s.apple_photos_background);
assert!(!lightcraft_engine::Session::new().apple_photos_background);
}
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
@@ -854,6 +1013,59 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn docking_survives_native_settings_file_reload() {
use lightcraft_ui_egui::docking::Panel;
let d = dir("docking-reload");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save_ui(&app.ui, false).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, false);
assert!(warning.is_none());
assert!(!keep);
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
restored_app.ui = restored.unwrap();
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
assert_eq!(
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
[70.0, 80.0, 310.0, 500.0]
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
let d = dir("future-docking");
let path = d.join("ui.json");
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
let mut saved = serde_json::to_value(&ui).unwrap();
saved["docking"] = docking;
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
let bytes = serde_json::to_vec(&saved).unwrap();
for set_aside in [false, true] {
std::fs::write(&path, &bytes).unwrap();
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
let loaded = loaded.unwrap();
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
assert_eq!(loaded.thumb_size, ui.thumb_size);
assert!(!loaded.filmstrip);
assert!(loaded.docking.is_none());
assert!(warning.unwrap().contains("Other settings were loaded"));
assert!(keep);
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
writer.save_ui(&loaded, false).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
}
}
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
#[test]
@@ -949,6 +1161,16 @@ mod tests {
}
}
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
#[test]
fn window_has_no_indirect_call_validation_pipeline() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
#[test]
@@ -1005,4 +1227,46 @@ mod tests {
assert!(loaded.is_some() && warning.is_none());
let _ = std::fs::remove_dir_all(&d);
}
/// #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)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}
+30
View File
@@ -0,0 +1,30 @@
//! The ICC profile of the screen showing LightCraft's window (macOS).
//!
//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's
//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so
//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in
//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the
//! previews are shown through this profile instead (`Services::screen_profile`).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSScreen};
/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None`
/// when it isn't known: off the main thread, or a screen without a usable profile.
pub fn icc() -> Option<Vec<u8>> {
let mtm = MainThreadMarker::new()?;
let app = NSApplication::sharedApplication(mtm);
let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject());
let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?;
let icc = screen.colorSpace()?.ICCProfileData()?.to_vec();
(!icc.is_empty()).then_some(icc)
}
#[cfg(test)]
mod tests {
#[test]
fn unknown_off_the_main_thread() {
// AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour
assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None);
}
}
+316
View File
@@ -0,0 +1,316 @@
//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails
//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in
//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up).
//!
//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then
//! eframe's glow) is *probed* before the window opens: a device is created with an error handler
//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first
//! that builds cleanly is handed to eframe as an existing device (so its error handler stays).
//! - **Everywhere:** a marker file names the renderer being started and is removed once the
//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that
//! renderer last; [`relaunch_after_failure`] starts that next launch right away.
//! - The renderer that worked is remembered (`<config>/window-renderer`, Windows only) and tried
//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one.
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting};
use eframe::wgpu;
use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer};
use std::path::PathBuf;
/// Environment variable counting automatic relaunches after a failed start (bounds the chain).
const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY";
/// Never more relaunches than renderers to try.
const MAX_RETRIES: u32 = 4;
/// The window's renderer for this start.
pub struct Chosen {
pub renderer: eframe::Renderer,
pub wgpu_options: WgpuConfiguration,
pub which: WindowRenderer,
/// For the user: the last start failed with another renderer, or one failed now.
pub notice: Option<String>,
}
/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`).
pub struct Files {
dir: Option<PathBuf>,
}
impl Files {
pub fn new(dir: Option<PathBuf>) -> Self {
Self { dir }
}
fn marker(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("window-init.marker"))
}
fn remembered(&self) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join("window-renderer"))
}
fn read(p: Option<PathBuf>) -> Option<WindowRenderer> {
std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse)
}
fn write(p: Option<PathBuf>, r: WindowRenderer) {
let Some(p) = p else { return };
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) {
log::warn!("window: could not write {}: {e}", p.display());
}
}
/// The renderer the last start died in (marker left behind), removing the marker.
pub fn take_failed(&self) -> Option<WindowRenderer> {
let m = self.marker()?;
let r = Self::read(Some(m.clone()));
let _ = std::fs::remove_file(m);
r
}
/// Before the window opens: record what is being started.
pub fn arm(&self, r: WindowRenderer) {
Self::write(self.marker(), r);
}
/// The window's renderer is up: clear the marker and (Windows) remember it.
pub fn started(&self, r: WindowRenderer) {
if let Some(m) = self.marker() {
let _ = std::fs::remove_file(m);
}
if cfg!(windows) {
Self::write(self.remembered(), r);
}
}
/// The marker is still there: the window never came up.
pub fn armed(&self) -> bool {
self.marker().is_some_and(|m| m.exists())
}
}
/// The renderer for this start: see the module docs.
pub fn choose(files: &Files) -> Chosen {
let forced =
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
let failed = files.take_failed();
let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None };
let compiled = wgpu::Instance::enabled_backend_features();
let os = std::env::consts::OS;
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
let mut problems = Vec::new();
let chosen = if cfg!(windows) {
pick_probed(&order, &mut problems)
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
Some((WindowRenderer::Glow, None))
} else {
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
};
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
if !problems.is_empty() {
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
}
let notice = notice.map(|n| {
format!(
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
(see docs/gpu-pipeline.md → Troubleshooting).",
which.name(),
app = "LightCraft",
var = "LIGHTCRAFT_GPU_BACKEND",
)
});
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
let mut wgpu_options = base_options();
let renderer = match (which, existing) {
(WindowRenderer::Glow, _) => eframe::Renderer::Glow,
(WindowRenderer::Wgpu(_), Some(e)) => {
wgpu_options.wgpu_setup = WgpuSetup::Existing(e);
eframe::Renderer::Wgpu
}
(WindowRenderer::Wgpu(b), None) => {
if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup {
n.instance_descriptor.backends = b;
}
eframe::Renderer::Wgpu
}
};
Chosen { renderer, wgpu_options, which, notice }
}
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
pub fn base_options() -> WgpuConfiguration {
let mut c = WgpuConfiguration::default();
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = backend::window_backends();
n.instance_descriptor.backend_options = backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags);
}
c
}
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
for &r in order {
match r {
WindowRenderer::Glow => return Some((r, None)),
WindowRenderer::Wgpu(b) => {
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
match probed {
Ok(e) => return Some((r, Some(e))),
Err(e) => {
log::error!("window: the {} renderer failed: {e}", r.name());
problems.push(format!("The {} renderer failed ({e}).", r.name()));
}
}
}
}
}
None
}
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
backend_options: backend::backend_options(),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
display: None,
});
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance),
..Default::default()
}))
.map_err(|e| format!("no adapter: {e}"))?;
let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() };
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base },
..Default::default()
}))
.map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?;
install_error_handler(&device);
check_pipeline(&device, |d| {
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
})?;
Ok(WgpuSetupExisting { instance, adapter, device, queue })
}
/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as
/// fatal by default"); this one logs.
pub fn install_error_handler(device: &wgpu::Device) {
device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}")));
}
/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is
/// returned instead of reaching the uncaptured-error handler.
pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> {
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
build(device);
let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())];
match errors.into_iter().flatten().next() {
Some(e) => Err(e.to_string()),
None => Ok(()),
}
}
/// The start failed (error or panic) before the window's renderer came up: start the app again,
/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to
/// disk, or the retries are used up).
pub fn relaunch_after_failure(files: &Files) -> bool {
if !files.armed() {
return false;
}
let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
if tries >= MAX_RETRIES {
return false;
}
let Ok(exe) = std::env::current_exe() else { return false };
log::warn!("window: the renderer failed to start; starting again with the next one");
std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.env(RETRY_VAR, (tries + 1).to_string())
.spawn()
.map_err(|e| log::error!("window: could not start again: {e}"))
.is_ok()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
/// The marker names the renderer being started; left behind, the next start reads it as the
/// failed one (and removes it); a good start removes it and remembers the renderer (Windows).
#[test]
fn marker_records_the_failed_renderer() {
let d = dir("marker");
let f = Files::new(Some(d.clone()));
let dx = WindowRenderer::Wgpu(Backends::DX12);
f.arm(dx);
assert!(f.armed());
assert_eq!(f.take_failed(), Some(dx));
assert!(!f.armed());
assert_eq!(f.take_failed(), None);
f.arm(WindowRenderer::Glow);
f.started(WindowRenderer::Glow);
assert!(!f.armed());
assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow));
// nothing written without a settings folder
let none = Files::new(None);
none.arm(dx);
assert!(!none.armed() && !relaunch_after_failure(&none));
let _ = std::fs::remove_dir_all(&d);
}
/// The crash report: a pipeline that fails validation must come back as an error, never as
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
#[test]
fn pipeline_errors_are_captured_not_panics() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: backend::window_backends(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
eprintln!("no GPU adapter: skipped");
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
eprintln!("no GPU device: skipped");
return;
};
install_error_handler(&device);
// a shader that does not validate, then a pipeline using it (like a failed FXC compile)
let r = check_pipeline(&device, |d| {
let m = d.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("broken"),
source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4<f32> { return undefined_thing; }".into()),
});
let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("egui_pipeline"),
layout: None,
vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] },
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
});
});
assert!(r.is_err(), "{r:?}");
// egui's own pipeline builds cleanly here
check_pipeline(&device, |d| {
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
})
.unwrap();
// outside a scope: logged by our handler, not a panic
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
}
}
+4
View File
@@ -12,7 +12,10 @@ 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/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 |
| `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 |
| `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 |
@@ -22,6 +25,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `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 |
| `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 |
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
+52
View File
@@ -0,0 +1,52 @@
{
"description": "Sony's global tone curve with the Dynamic Range Optimizer (DRO) off, Standard creative style: knots of scene luminance (white-balanced camera RGB with baseline exposure, through the colour model) to display-linear luminance. The camera look of an ARW shot with DRO on is lowered to it wherever it is darker (crates/engine/src/camera_preview.rs, docs/camera-preview-colour.md, issue #244).",
"derivation": "Each source raw was paired with its own embedded camera JPEG at the 96-pixel proxy of the per-photo fit (camera_preview::collect_pairs). The pairs of all 12 files were pooled as one model, as lightcraft-cli calibrate pools a camera profile: a colour matrix and hue/saturation table were fitted to the pool, and the tone curve was quantile-matched (camera_preview::fit_tone) between the pooled scene luminances through that colour model and the JPEG luminances. Knots rounded to 5 decimals. Aggregate statistics only, no image content.",
"sources": [
{ "body": "ILCA-77M2", "url": "https://raw.pixls.us/data/Sony/ILCA-77M2/_DSC0244.ARW", "sha256": "f41ceff680b675460cc1fe258a24d8c8c01c52a300933f3ab6401d9a20b60dcc", "licence": "CC0" },
{ "body": "ILCE-3500", "url": "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", "sha256": "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", "licence": "CC0" },
{ "body": "ILCE-5000", "url": "https://raw.pixls.us/data/Sony/ILCE-5000/_DSC0793.ARW", "sha256": "a919c8535eed68577f1745fb9674ccf333962628ccdec6d48a6f635208467f6c", "licence": "CC0" },
{ "body": "ILCE-6600", "url": "https://raw.pixls.us/data/Sony/ILCE-6600/_DSC0268.ARW", "sha256": "041d71d70148ca77164f19d061e3582adb34399ea5a4baba32960f2956fb0362", "licence": "CC0" },
{ "body": "ILCE-7CR", "url": "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", "sha256": "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", "licence": "CC0" },
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/UNCOMPRESSED_14bit.ARW", "sha256": "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", "licence": "CC0" },
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0009.ARW", "sha256": "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", "licence": "CC0" },
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0010.ARW", "sha256": "3ae2ff75cfa7240d0b4458f662afd28665c5fd1e83d230ac73f5ad69d3df8592", "licence": "CC0" },
{ "body": "ILCE-7S", "url": "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", "sha256": "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", "licence": "CC0" },
{ "body": "ILCE-9M2", "url": "https://raw.pixls.us/data/Sony/ILCE-9M2/SONY_A9II_(ILCE-9M2)_-_compressed_(3:2)_14bit.ARW", "sha256": "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", "licence": "CC0" },
{ "body": "NEX-F3", "url": "https://raw.pixls.us/data/Sony/NEX-F3/_DSC4017.ARW", "sha256": "cfdc60d118b5ce2dce10d459dcb98ad63c6db3e609999932ca8964d86daf836b", "licence": "CC0" },
{ "body": "SLT-A57", "url": "https://raw.pixls.us/data/Sony/SLT-A57/a57-06125.arw", "sha256": "0e9064f369a3947f892c1d7b1971f75d3879f5673134932d5cc7a67bf4263ba4", "licence": "CC0" }
],
"knots": [
[0.01655, 0.01799],
[0.01970, 0.02376],
[0.02212, 0.03008],
[0.02469, 0.03634],
[0.02737, 0.04320],
[0.03049, 0.05044],
[0.03425, 0.05812],
[0.03824, 0.06667],
[0.04232, 0.07817],
[0.04658, 0.09059],
[0.05095, 0.10268],
[0.05550, 0.11731],
[0.05962, 0.13173],
[0.06409, 0.14529],
[0.06843, 0.15939],
[0.07314, 0.17363],
[0.07800, 0.18897],
[0.08298, 0.20502],
[0.08909, 0.22159],
[0.09693, 0.23964],
[0.10540, 0.26082],
[0.11579, 0.28882],
[0.12763, 0.31986],
[0.14079, 0.35198],
[0.15786, 0.39293],
[0.17949, 0.44101],
[0.20330, 0.49407],
[0.23466, 0.54244],
[0.27207, 0.60078],
[0.31673, 0.66533],
[0.38682, 0.74143],
[0.52622, 0.80307]
]
}
+250 -63
View File
@@ -2,23 +2,23 @@
"schema": 1,
"repo": "storytold/lightcraft",
"branch": "main",
"head": "88ea1a804b35709e1fe436e3792adf0428b97daa",
"head_date": "2026-10-09T13:05:04Z",
"commits": 729,
"head": "c545cc5e842a74d65f32187d9fdee86ada3099de",
"head_date": "2026-10-10T05:40:18Z",
"commits": 1033,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 83,
"commits": 348,
"lines_added": 127920,
"lines_deleted": 8745,
"prs": 85,
"commits": 370,
"lines_added": 131038,
"lines_deleted": 9013,
"binary_added": 38,
"binary_deleted": 8,
"first_commit": "2026-09-30T14:04:12Z",
"last_commit": "2026-10-09T13:05:04Z"
"last_commit": "2026-10-10T05:56:02Z"
},
{
"login": "leipsfur",
@@ -63,14 +63,14 @@
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 755,
"lines_deleted": 80,
"prs": 6,
"commits": 6,
"lines_added": 884,
"lines_deleted": 93,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:07:26Z",
"last_commit": "2026-10-09T06:04:40Z"
"last_commit": "2026-10-09T18:24:49Z"
},
{
"login": "voshart",
@@ -85,6 +85,19 @@
"first_commit": "2026-10-05T16:23:27Z",
"last_commit": "2026-10-09T02:31:04Z"
},
{
"login": "akvaithi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 5,
"lines_added": 1799,
"lines_deleted": 114,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:53:56Z",
"last_commit": "2026-10-05T19:55:59Z"
},
{
"login": "arata-1972",
"people_entry": false,
@@ -128,14 +141,14 @@
"login": "Exalted5373",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 11,
"lines_added": 1988,
"lines_deleted": 132,
"prs": 9,
"commits": 15,
"lines_added": 4316,
"lines_deleted": 309,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T21:01:23Z",
"last_commit": "2026-10-09T01:33:43Z"
"last_commit": "2026-10-09T21:58:32Z"
},
{
"login": "treyreynolds",
@@ -258,14 +271,14 @@
"login": "radialmonster",
"people_entry": false,
"github_name": null,
"prs": 26,
"commits": 53,
"lines_added": 7227,
"lines_deleted": 717,
"prs": 35,
"commits": 64,
"lines_added": 8732,
"lines_deleted": 822,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T12:33:51Z",
"last_commit": "2026-10-09T11:31:46Z"
"last_commit": "2026-10-10T02:01:36Z"
},
{
"login": "bogusyogi",
@@ -284,14 +297,14 @@
"login": "nledford",
"people_entry": false,
"github_name": null,
"prs": 10,
"commits": 88,
"lines_added": 9299,
"lines_deleted": 1024,
"prs": 19,
"commits": 305,
"lines_added": 24796,
"lines_deleted": 2896,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:33:38Z",
"last_commit": "2026-10-09T00:59:12Z"
"last_commit": "2026-10-09T23:05:22Z"
},
{
"login": "MusseJusse",
@@ -349,7 +362,7 @@
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 2,
"prs": 3,
"commits": 2,
"lines_added": 206,
"lines_deleted": 42,
@@ -544,14 +557,14 @@
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 6,
"lines_added": 336,
"lines_deleted": 29,
"prs": 6,
"commits": 7,
"lines_added": 351,
"lines_deleted": 36,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:44:47Z",
"last_commit": "2026-10-09T08:45:45Z"
"last_commit": "2026-10-09T17:29:39Z"
},
{
"login": "peiyan0",
@@ -635,14 +648,14 @@
"login": "sanyamsingh001",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 431,
"lines_deleted": 19,
"prs": 4,
"commits": 5,
"lines_added": 841,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T03:19:47Z",
"last_commit": "2026-10-09T03:19:47Z"
"last_commit": "2026-10-10T01:46:07Z"
},
{
"login": "onemannoplan",
@@ -674,40 +687,170 @@
"login": "HarrisXiu",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 69,
"lines_deleted": 1,
"prs": 6,
"commits": 6,
"lines_added": 1247,
"lines_deleted": 80,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T09:34:51Z",
"last_commit": "2026-10-09T09:34:51Z"
"last_commit": "2026-10-10T05:46:24Z"
},
{
"login": "rock-meister",
"people_entry": true,
"github_name": "rock-meister",
"prs": 3,
"commits": 4,
"lines_added": 263,
"lines_deleted": 44,
"prs": 4,
"commits": 5,
"lines_added": 291,
"lines_deleted": 46,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T10:15:52Z",
"last_commit": "2026-10-09T11:26:47Z"
"last_commit": "2026-10-09T15:00:07Z"
},
{
"login": "QuirkyTurtle94",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 153,
"lines_deleted": 39,
"prs": 5,
"commits": 5,
"lines_added": 475,
"lines_deleted": 77,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T11:08:11Z",
"last_commit": "2026-10-09T11:08:11Z"
"last_commit": "2026-10-09T14:32:04Z"
},
{
"login": "AER00",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 207,
"lines_deleted": 21,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:11Z",
"last_commit": "2026-10-09T13:57:38Z"
},
{
"login": "AlexChambers",
"people_entry": false,
"github_name": null,
"prs": 11,
"commits": 16,
"lines_added": 7202,
"lines_deleted": 954,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:59:07Z",
"last_commit": "2026-10-10T05:40:18Z"
},
{
"login": "bitshiftza",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 342,
"lines_deleted": 97,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T15:03:06Z",
"last_commit": "2026-10-09T18:14:49Z"
},
{
"login": "vbasky",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 622,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:43:31Z",
"last_commit": "2026-10-09T17:43:31Z"
},
{
"login": "kelanna",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1169,
"lines_deleted": 24,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:51:06Z",
"last_commit": "2026-10-09T17:51:06Z"
},
{
"login": "dmatviichuk",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2292,
"lines_deleted": 44,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T18:02:42Z",
"last_commit": "2026-10-09T18:02:42Z"
},
{
"login": "GarrettGR",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 5,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T19:37:15Z",
"last_commit": "2026-10-09T19:37:15Z"
},
{
"login": "dylanmtaylor",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 38,
"lines_deleted": 44,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:04:24Z",
"last_commit": "2026-10-10T00:04:24Z"
},
{
"login": "sawizzle",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 23,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:06:24Z",
"last_commit": "2026-10-10T04:50:51Z"
},
{
"login": "KitsuneSemCalda",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 11,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T01:50:45Z",
"last_commit": "2026-10-10T01:50:45Z"
},
{
"login": "BuonBreda",
@@ -735,6 +878,19 @@
"first_commit": null,
"last_commit": null
},
{
"login": "enzoflayac",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "GuintherKovalski",
"people_entry": false,
@@ -761,6 +917,19 @@
"first_commit": null,
"last_commit": null
},
{
"login": "pikkemaat",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "stano-bustor",
"people_entry": false,
@@ -794,9 +963,9 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 475,
"lines_added": 147839,
"lines_deleted": 10702
"commits": 747,
"lines_added": 178263,
"lines_deleted": 14199
},
{
"id": "anthropic/claude-sonnet/5.5",
@@ -812,18 +981,18 @@
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 65,
"lines_added": 8385,
"lines_deleted": 952
"commits": 66,
"lines_added": 8450,
"lines_deleted": 956
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 33,
"lines_added": 14418,
"lines_deleted": 1647
"commits": 38,
"lines_added": 17120,
"lines_deleted": 1703
},
{
"id": "anthropic/claude-fable/5.1",
@@ -870,6 +1039,15 @@
"lines_added": 1895,
"lines_deleted": 294
},
{
"id": "openai/gpt-6-luna",
"company": "OpenAI",
"model": "GPT-6 Luna",
"version": "",
"commits": 1,
"lines_added": 68,
"lines_deleted": 19
},
{
"id": "openai/gpt/6",
"company": "OpenAI",
@@ -878,6 +1056,15 @@
"commits": 1,
"lines_added": 2864,
"lines_deleted": 261
},
{
"id": "opencode/opencode",
"company": "opencode",
"model": "opencode",
"version": "",
"commits": 1,
"lines_added": 459,
"lines_deleted": 5
}
]
}
+2
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
lightcraft-develop = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-raw = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -19,4 +20,5 @@ log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
lightcraft-geom = { workspace = true }
proptest = { workspace = true }
+17 -3
View File
@@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
/// A date without a time gets one. `None` if it doesn't parse.
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
/// anything, it never overflows.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
let t = iso_seconds(iso)?;
let t = iso_seconds(iso)?.checked_add(secs)?;
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
if !(first..=last).contains(&t) {
return None;
}
let tail = iso.trim().get(19..).unwrap_or("");
Some(format!("{}{tail}", civil(t + secs)))
Some(format!("{}{tail}", civil(t)))
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
@@ -204,6 +210,14 @@ mod tests {
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
// hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date
// here can read back) is refused rather than stored
for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] {
assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}");
}
assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None);
assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None);
assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59"));
assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00"));
assert_eq!(normalize_iso("2026-02-30"), None);
}
+95 -7
View File
@@ -23,15 +23,21 @@
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
//!
//! A folder is otherwise only where photos are, but the library can also keep a
//! [`FolderRecord`] about it (its colour label), stored under the folder's identity
//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved
//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is
//! kept (undo can bring the photos back) and shows again when the folder does.
//!
//! This is the library's own view of its photos; browsing any folder on disk without importing
//! is Local's job (see [`crate::local`]).
use std::collections::{BTreeMap, HashMap};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::query::{folder_key, folder_within, split_path};
use crate::{Catalog, Source};
use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path};
use crate::{Catalog, ColorLabel, Op, Source};
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
@@ -58,9 +64,35 @@ pub struct FolderNode {
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
/// path covers those disks' photos too, so choosing them is not offered.
pub selectable: bool,
/// The folder's colour label (see [`FolderRecord`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<ColorLabel>,
pub children: Vec<FolderNode>,
}
/// What the library keeps about one of its folders beyond the photos in it. A record with
/// nothing in it is not kept.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FolderRecord {
/// A colour label, as a photo has one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<ColorLabel>,
}
impl FolderRecord {
/// Whether the record says nothing (and so is not kept).
pub fn is_empty(&self) -> bool {
self.label.is_none()
}
}
/// The identity a folder's record is kept under: its [`folder_key`]; `None` for a path that
/// names no folder (empty, relative, `.`).
pub(crate) fn record_key(path: &str) -> Option<String> {
let key = folder_key(path);
(split_path(path, true).absolute && !key.is_empty()).then_some(key)
}
/// A folder while the tree is being built (children by identity key).
struct Level {
name: String,
@@ -151,6 +183,53 @@ pub fn folder_label(path: &str) -> String {
}
impl Catalog {
/// What the library keeps about folder `path`, however it is spelled.
pub fn folder_record(&self, path: &str) -> Option<&FolderRecord> {
record_key(path).and_then(|k| self.folder_records.get(&k))
}
/// The colour label of folder `path`, however it is spelled.
pub fn folder_color_label(&self, path: &str) -> Option<ColorLabel> {
self.folder_record(path).and_then(|r| r.label)
}
/// The op that gives folder `path` colour label `label` (`None`: takes it off), keeping the
/// rest of its record. Applying it checks the path.
pub fn folder_label_op(&self, path: &str, label: Option<ColorLabel>) -> Op {
let mut record = self.folder_record(path).cloned().unwrap_or_default();
record.label = label;
Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
}
/// The ops that carry the records of folder `from` and the folders inside it over to `to`
/// (the same place below it), for a folder renamed or moved on disk: commit them with the
/// photos' relinks so undo puts both back. Records already at or below `to` are left from a
/// folder that is no longer there and are dropped (undo restores them). Empty when there is
/// nothing to carry or drop.
pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec<Op> {
let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
if root == dest {
return Vec::new();
}
let moved: Vec<(String, FolderRecord)> = self
.folder_records
.iter()
.filter(|(k, _)| key_within(k, &root))
.filter_map(|(k, r)| {
let rest = folder_rest(k, &root)?;
let base = dest.trim_end_matches('/');
let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) };
Some((folder_key(&path), r.clone()))
})
.collect();
// what was known at the old place goes, and so does what is left at the new one (also
// when the moved folder carries nothing: the folder now there is not the one it was about)
let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest));
let mut ops: Vec<Op> = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect();
ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) }));
ops
}
/// The library's volumes with their folders and photo counts: volumes by name
/// (case-insensitive), each folder with its subfolders the same way.
pub fn folder_tree(&self) -> Vec<FolderNode> {
@@ -196,7 +275,7 @@ impl Catalog {
l.own += n;
}
}
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect();
sort(&mut tree);
// a folder that holds another disk's mount covers that disk's photos too
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
@@ -206,12 +285,21 @@ impl Catalog {
}
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
fn build(key: &str, levels: &HashMap<String, Level>, records: &BTreeMap<String, FolderRecord>) -> Option<FolderNode> {
let l = levels.get(key)?;
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels, records)).collect();
sort(&mut children);
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
Some(FolderNode {
name: l.name.clone(),
path: l.path.clone(),
count,
own: l.own,
volume: l.volume,
selectable: true,
label: records.get(key).and_then(|r| r.label),
children,
})
}
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
+6 -1
View File
@@ -46,6 +46,11 @@
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -70,7 +75,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 = 3;
pub const VERSION: u32 = 8;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
File diff suppressed because it is too large. Load diff
+151 -4
View File
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
pub use folders::FolderNode;
pub use folders::{FolderNode, FolderRecord};
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
@@ -52,6 +52,9 @@ pub enum CatalogError {
NoStack(StackId),
#[error("invalid: {0}")]
Invalid(String),
/// A keyword of that name is there already: moving or renaming onto it would merge the two.
#[error("there is a keyword “{0}” already")]
KeywordExists(String),
#[error("corrupt catalog data: {0}")]
Corrupt(String),
#[error("catalog storage: {0}")]
@@ -122,6 +125,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,
},
@@ -206,6 +215,18 @@ pub enum Op {
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
},
/// The lens data a photo's file carries (what Reload finds when it was read after import).
SetEmbeddedLens {
id: PhotoId,
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
},
/// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the
/// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping,
/// not an edit: the look does not change, only the As Shot readout.
SetAsShotWb {
id: PhotoId,
wb: Option<(f64, f64)>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
@@ -217,6 +238,18 @@ pub enum Op {
folder: String,
at: Option<String>,
},
/// List a keyword in the library's keyword list with these attributes, or take it off the list
/// (`None`; photos that carry it keep it). Format version 4.
SetKeyword {
path: String,
info: Option<keywords::KeywordInfo>,
},
/// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under
/// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing.
SetFolderRecord {
folder: String,
record: Option<FolderRecord>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@@ -239,6 +272,12 @@ pub struct Catalog {
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap<String, String>,
/// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
keyword_list: BTreeMap<String, keywords::ListedKeyword>,
/// What the library keeps about its folders (folder identity → record), see [`folders`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
folder_records: BTreeMap<String, FolderRecord>,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@@ -324,12 +363,57 @@ impl Catalog {
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self),
// 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),
None => false,
}
}
/// 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.
pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool {
let mut seen: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
let mut next = vec![from];
while let Some(a) = next.pop() {
if !seen.insert(a) {
continue;
}
let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
if tested.contains(&to) {
return true;
}
next.extend(tested);
}
false
}
/// What is wrong with a saved smart album's rules now (`RuleSet::check`, after
/// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty
/// for a sound smart album, a plain album or no album.
pub fn smart_album_problems(&self, id: AlbumId) -> Vec<rules::Problem> {
let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
let mut out: Vec<rules::Problem> = self.album_filter_problem(filter, Some(id)).into_iter().collect();
if let Some(mut rules) = filter.rule_set.clone() {
rules.upgrade();
out.extend(rules.check_for(self, Some(id)));
}
out
}
/// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to
/// itself, or to an album that leads back to it.
pub fn album_filter_problem(&self, filter: &Filter, owner: Option<AlbumId>) -> Option<rules::Problem> {
let (a, owner) = (filter.album?, owner?);
(a == owner || self.album_reaches(a, owner)).then(|| rules::Problem {
path: Vec::new(),
field: Some("album".into()),
issue: rules::Issue::AlbumLoop,
message: format!("its album filter (album {}) would make this album include itself", a.0),
})
}
/// 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) {
@@ -366,6 +450,18 @@ impl Catalog {
pub fn label_name(&self, l: ColorLabel) -> String {
self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}"))
}
/// A listed keyword's attributes (any case), `None` when it isn't listed.
pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info)
}
/// How the keyword list spells a listed keyword (any case), `None` when it isn't listed.
pub fn listed_path(&self, path: &str) -> Option<&str> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str())
}
/// The keyword list: keywords listed on their own or given attributes, by path.
pub fn listed_keywords(&self) -> impl Iterator<Item = &keywords::ListedKeyword> {
self.keyword_list.values()
}
/// The custom name of a colour label, if any.
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
@@ -373,7 +469,9 @@ impl Catalog {
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
let name = name.trim();
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
// any case, accents included (Été / été), as rules compare names
let name_lower = name.to_lowercase();
self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@@ -453,12 +551,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) {
@@ -596,6 +707,17 @@ impl Catalog {
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
}
}
Op::SetEmbeddedLens { id, lens } => {
let p = self.photo_mut(id)?;
Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
}
Op::SetAsShotWb { id, wb } => {
if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {
return Err(CatalogError::Invalid("as-shot white balance is not a number".into()));
}
let p = self.photo_mut(id)?;
Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) }
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
return Err(CatalogError::Invalid("empty file name".into()));
@@ -612,6 +734,21 @@ impl Catalog {
};
Op::SetLabelName { label, name: old }
}
Op::SetKeyword { path, info } => {
let path = keywords::clean(&path);
if path.is_empty() {
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
}
let key = path.to_lowercase();
let old = match info {
Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }),
None => self.keyword_list.remove(&key),
};
match old {
Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) },
None => Op::SetKeyword { path, info: None },
}
}
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
@@ -620,6 +757,14 @@ impl Catalog {
};
Op::SetBrowsed { folder, at: old }
}
Op::SetFolderRecord { folder, record } => {
let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?;
let old = match record.filter(|r| !r.is_empty()) {
Some(r) => self.folder_records.insert(key.clone(), r),
None => self.folder_records.remove(&key),
};
Op::SetFolderRecord { folder: key, record: old }
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
@@ -721,6 +866,8 @@ mod tests_album_order;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_folder_records;
#[cfg(test)]
mod tests_folders;
#[cfg(test)]
mod tests_format_version;
+7 -2
View File
@@ -279,11 +279,14 @@ impl Op {
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetHistoryCursor { id, .. }
| Op::SetCaptured { id, .. }
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
| Op::SetContent { id, .. } => out(*id),
| Op::SetContent { id, .. }
| Op::SetEmbeddedLens { id, .. }
| Op::SetAsShotWb { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
@@ -299,7 +302,9 @@ impl Op {
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. } => {}
| Op::SetKeyword { .. }
| Op::SetBrowsed { .. }
| Op::SetFolderRecord { .. } => {}
}
}
}
+29 -7
View File
@@ -7,9 +7,15 @@
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
//!
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
//! that's actually held refuses the open.
//! it is released explicitly when the [`LibraryLock`] is dropped and, after a crash or a kill, by
//! the OS when the process ends. A `catalog.lock` file left behind is therefore never "stale":
//! only a lock that's actually held refuses the open.
//! - Dropping the lock calls [`std::fs::File::unlock`] rather than relying on the handle closing.
//! On Unix an `flock` lock belongs to the open file description and lasts while *any* descriptor
//! on it is open; a process that another thread is spawning holds a copy of every descriptor
//! (`O_CLOEXEC` only closes them at `execve`), so closing ours would leave the library "already
//! open" for a moment and a re-open would be refused (issue #680). `LOCK_UN` through our
//! descriptor frees the lock whatever copies a child still holds.
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
//! the error message. It's a separate file because a Windows lock also blocks reading the
//! locked file. It's only a hint: it may be missing or left over from a crash.
@@ -87,7 +93,7 @@ pub enum LockError {
pub struct LibraryLock {
dir: PathBuf,
/// `None` when the file system can't lock (the library is open unlocked).
_file: Option<File>,
file: Option<File>,
held: bool,
}
@@ -96,7 +102,7 @@ impl LibraryLock {
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
let unlocked = |why: io::Error| {
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
LibraryLock { dir: dir.to_path_buf(), file: None, held: false }
};
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
Ok(f) => f,
@@ -116,7 +122,7 @@ impl LibraryLock {
{
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
}
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
Ok(LibraryLock { dir: dir.to_path_buf(), file: Some(file), held: true })
}
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
@@ -127,14 +133,30 @@ impl LibraryLock {
pub fn dir(&self) -> &Path {
&self.dir
}
/// Another descriptor on the locked file: what a process spawned by another thread holds
/// between fork and exec (a `dup` refers to the same open file description). Tests only (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())
}
}
impl Drop for LibraryLock {
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
/// then the file handle closes and the OS releases the lock.
/// then release the lock explicitly. Closing the handle isn't enough on Unix: `flock` lasts
/// while any copy of the descriptor is open, and a process another thread is spawning holds a
/// copy of every descriptor until it execs (issue #680). If the unlock fails the handle still
/// closes right after, which releases the lock once every copy is gone, as before.
fn drop(&mut self) {
if self.held {
let _ = std::fs::remove_file(self.dir.join(OWNER));
if let Some(f) = &self.file
&& let Err(e) = f.unlock()
{
log::warn!("library {}: can't release {LOCK}: {e}", self.dir.display());
}
}
}
}
+178 -11
View File
@@ -152,6 +152,17 @@ impl CopyrightStatus {
}
}
/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for
/// anything that isn't a positive, finite time.
pub fn parse_shutter_seconds(s: &str) -> Option<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
None => s.parse().ok(),
}
.filter(|v: &f64| v.is_finite() && *v > 0.0)
}
/// Descriptive + capture metadata.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -236,6 +247,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,
@@ -296,7 +312,33 @@ pub struct Analysis {
pub best: bool,
}
/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting
/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same
/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, 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() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
}
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,
@@ -317,6 +359,7 @@ impl Photo {
edited: None,
versions: Vec::new(),
history: Vec::new(),
history_cursor: None,
deleted: false,
local: false,
duration: None,
@@ -336,35 +379,88 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
/// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor
/// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies'
/// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats.
pub fn fitted_look(&self) -> bool {
self.develops_raw() && relative_wb_format(&self.format)
}
/// White balance relative to the camera's as-shot look, as for rendered photographs: a
/// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either
/// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin.
pub fn relative_wb(&self) -> bool {
self.develops_raw()
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera)
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
/// in the photo's own rendering process.
pub fn import_defaults(&self) -> DevelopSettings {
match &self.import_look {
Some(l) => (**l).clone(),
Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() },
None => self.camera_defaults(),
}
}
/// The built-in defaults for this photo, before any user default preset: raws start from
/// their as-shot white balance; embedded lens corrections on when the file has them.
/// their as-shot white balance; embedded lens corrections on when the file has them. They
/// carry the photo's own rendering process ([`lightcraft_develop::ProcessVersion`]): which
/// process a photo renders with is not an edit, so comparisons with its defaults (edited?
/// still as imported?) don't change when a newer process exists. A new photo has the latest.
pub fn camera_defaults(&self) -> DevelopSettings {
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let relative = self.relative_wb();
let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let mut d = match wb {
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
None => DevelopSettings::default(),
};
if self.develops_raw() && !relative {
d.wb.scale = lightcraft_develop::WbScale::Kelvin;
}
if self.embedded_lens.is_some() {
d.optics.lens_profile = true;
}
d.process = self.develop.process;
d
}
/// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty
/// when the name has none (`README`, a dot file such as `.hidden`).
pub fn extension(&self) -> &str {
self.file_name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()).map_or("", |(_, ext)| ext)
}
/// Width and height in pixels as the photo is shown: turned by its orientation and cut by its
/// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the
/// aspect is exact and the edges can read a little larger than an export.
pub fn shown_size(&self) -> (f64, f64) {
let (w, h) = (self.width as f64, self.height as f64);
let (w, h) = if self.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
let r = self.develop.crop.geometry.rect;
// a crop read from a file or an agent is hostile: no NaN, nothing outside the frame
let part = |len: f64| if len.is_finite() { len.clamp(0.0, 1.0) } else { 1.0 };
(w * part(r.width()), h * part(r.height()))
}
/// Cropped or straightened.
pub fn is_cropped(&self) -> bool {
!self.develop.crop.geometry.is_identity()
}
/// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and
/// the same keyword in different case or with stray spaces counts once; blank ones don't count.
pub fn keyword_count(&self) -> usize {
let mut seen: Vec<String> = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect();
seen.sort_unstable();
seen.dedup();
seen.len()
}
/// The people in the photo: the names on its face regions, trimmed, each once (names that
/// differ only in case are one person, as first seen). Pets and unnamed faces are not people.
pub fn people(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for r in self.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 };
if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) {
out.push(name);
}
}
out
}
/// In the library: not deleted and not only browsed (Local).
pub fn in_library(&self) -> bool {
!self.deleted && !self.local
@@ -424,6 +520,7 @@ impl Album {
#[cfg(test)]
mod edited_tests {
use super::*;
use lightcraft_develop::WbScale;
#[test]
fn raw_with_import_settings_is_not_edited() {
@@ -439,6 +536,32 @@ mod edited_tests {
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn the_process_a_photo_renders_with_is_not_an_edit() {
use lightcraft_develop::ProcessVersion;
// a photo on another process than the latest (a V1 photo once a later process is the
// latest, or a photo saved by a newer LightCraft)
let other = ProcessVersion(ProcessVersion::LATEST.0 + 1);
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.dng", "DNG", 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.camera_defaults().process, ProcessVersion::LATEST, "a new photo gets the latest");
p.develop = Arc::new(DevelopSettings { process: other, ..p.import_defaults() });
assert_eq!((p.camera_defaults().process, p.import_defaults().process), (other, other));
assert!(!p.is_edited(), "still as imported");
// with a default preset's look as well
let mut look = DevelopSettings::for_raw(5200.0, 4.0);
look.light.exposure = 0.3;
p.import_look = Some(Arc::new(look.clone()));
p.develop = Arc::new(DevelopSettings { process: other, ..look });
assert_eq!(p.import_defaults().process, other);
assert!(!p.is_edited());
let mut d = (*p.develop).clone();
d.light.exposure = 1.0;
p.develop = Arc::new(d);
assert!(p.is_edited(), "an edit still is one");
}
#[test]
fn supported_raws_use_relative_white_balance() {
for (name, format, relative) in [
@@ -451,14 +574,20 @@ mod edited_tests {
("a.raf", "rAf", true),
("a.cr3", "cr3", true),
("a.srw", "SRW", true),
("a.cr2", "CR2", true),
("a.pef", "pef", true),
("a.orf", "ORF", 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");
p.kind = MediaKind::Raw;
p.meta.camera = "Pentax K-1".into();
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.relative_wb(), relative, "{format}");
assert_eq!(p.fitted_look(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}");
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
let mut custom = (*p.develop).clone();
@@ -467,7 +596,45 @@ mod edited_tests {
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
assert!(!p.relative_wb() && !p.fitted_look());
}
}
/// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730):
/// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on.
#[test]
fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() {
for (camera, format, relative) in [
("SONY ILCE-7M3", "ARW", false),
("SONY ILCE-7RM4A", "ARW", false),
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
("SONY ILCE-7M2", "ARW", true),
("SONY ILCA-99M2", "ARW", false),
("SONY SLT-A33", "ARW", false),
("SONY ILCE-1", "ARW", true),
("", "ARW", true),
("SONY ILCE-7M3", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = camera.into();
p.as_shot_wb = Some((4986.0, -2.0));
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
// a photo from an older catalog: stored As Shot on the relative scale, still not an edit
p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0));
assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit");
// its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot
p.as_shot_wb = Some((6500.0, 0.0));
assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot);
assert!(!p.is_edited());
}
}
}
+31 -4
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};
@@ -154,12 +156,21 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
/// Whether `p` has a named face region called `name` (case-insensitive).
fn has_person(p: &Photo, name: &str) -> bool {
let name = name.trim().to_lowercase();
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
p.people().iter().any(|n| n.to_lowercase() == name)
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
self.describe_in(None)
}
/// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`).
pub fn describe_with(&self, cat: &crate::Catalog) -> String {
self.describe_in(Some(cat))
}
fn describe_in(&self, cat: Option<&crate::Catalog>) -> String {
let mut v: Vec<String> = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
@@ -207,11 +218,18 @@ impl Filter {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
v.push(rs.describe());
v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c)));
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
/// The albums this filter tests: its album field and its rules' Album rules.
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
let mut out: Vec<crate::AlbumId> = self.album.into_iter().collect();
out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default());
out
}
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
@@ -343,8 +361,13 @@ 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();
@@ -353,11 +376,15 @@ impl Catalog {
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::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(sort.seed, a.id).cmp(&shuffle_rank(sort.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 sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
File diff suppressed because it is too large. Load diff
+24 -5
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 {
@@ -203,17 +204,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]),
@@ -264,6 +267,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));
+236
View File
@@ -457,6 +457,199 @@ fn random_sort_has_no_date_headers() {
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
}
/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back.
#[test]
fn set_embedded_lens_is_undoable_and_journaled() {
let mut c = Catalog::default();
let a = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
})
.unwrap();
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };
let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
let inv = c.apply(op).unwrap();
assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens));
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
}
/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the
/// journal's JSON) takes it back, and a value that is not a number is refused.
#[test]
fn set_as_shot_wb_is_undoable_journaled_and_checked() {
let mut c = Catalog::default();
let a = c.alloc_photo_id();
let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00");
p.as_shot_wb = Some((6500.0, 0.0));
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) };
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
let inv = c.apply(op).unwrap();
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0)));
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err());
assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err());
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
}
/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
/// with its problems; a good one has none; an old album operator is read as it was meant.
#[test]
fn smart_album_problems_flag_stale_rules() {
let mut c = Catalog::new();
c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
let smart = |id: u64, rules: serde_json::Value| {
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap();
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") }
};
c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap();
c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap();
assert!(c.smart_album_problems(AlbumId(2)).is_empty());
assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem");
assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules");
c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap();
let p = c.smart_album_problems(AlbumId(2));
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 1: no album 1".to_string()]);
}
/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded
/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they
/// change.
#[test]
fn a_smart_album_can_exclude_another() {
let mut c = Catalog::new();
let smart = |id: u64, name: &str, rules: serde_json::Value| {
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap();
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) }
};
c.apply(Op::AddAlbum {
album: smart(
1,
"Excluded Photos",
serde_json::json!({"match": "any", "rules": [
{"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}),
),
})
.unwrap();
let travel = |c: &mut Catalog, name: &str, label: Option<ColorLabel>, flag: Flag| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.meta.keywords = vec!["travel".into()];
p.label = label;
p.flag = flag;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let keep = travel(&mut c, "keep.jpg", None, Flag::Pick);
let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None);
let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject);
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
{"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]}))
.unwrap();
assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c));
let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::<Vec<_>>();
assert_eq!(matched(&c), vec![keep]);
// "is" works the other way round
let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap();
assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2);
let _ = (red, rejected);
}
/// An album can't include itself, directly or through other smart albums: the check refuses the
/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file)
/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no
/// photos, and every album in it is reported.
#[test]
fn smart_album_loops_are_refused_and_survived() {
let mut c = Catalog::new();
let refers = |to: u64| -> Filter {
serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap()
};
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
// A → B → C
c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap();
c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap();
c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap();
let rules = |to: u64| refers(to).rule_set.unwrap();
assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A");
assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself");
assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet");
assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop");
assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3)));
// a loop saved anyway: C → A
c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap();
let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00");
let p = c.photo(id).unwrap().clone();
for a in [1, 2, 3] {
let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow
assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported");
}
}
/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids;
/// one that is gone shows as its number.
#[test]
fn summaries_name_albums() {
let mut c = Catalog::new();
c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap();
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
{"field": "keywords", "op": "contains", "value": "travel"},
{"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]}))
.unwrap();
assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)");
let f = Filter { rule_set: Some(rules), ..Default::default() };
assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”"));
}
/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is
/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once
/// per question. 25 albums deep stays instant.
#[test]
fn chains_of_smart_albums_stay_linear() {
let mut c = Catalog::new();
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") };
c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap();
for k in 2..=26u64 {
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [
{"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}}))
.unwrap();
c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap();
}
for i in 0..20 {
photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00");
}
let start = std::time::Instant::now();
assert_eq!(c.album_count(AlbumId(26)), 20);
assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
}
/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is
/// reported, and so is A filtered to B while B's rules test A.
#[test]
fn an_album_filter_counts_in_loops() {
let mut c = Catalog::new();
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap();
let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop);
assert!(loops(&c, 1), "filtered to itself");
let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap();
c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap();
c.apply(Op::AddAlbum {
album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()),
})
.unwrap();
assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3)));
assert!(loops(&c, 2) && loops(&c, 3));
let _ = tests_3;
let shown: Vec<String> = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect();
assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}");
}
#[test]
fn undated_photos_group_under_unknown_date_and_sort_together() {
let mut c = Catalog::new();
@@ -501,3 +694,46 @@ 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);
}
+199
View File
@@ -0,0 +1,199 @@
//! What the library keeps about its folders: a colour label, like a photo's (see
//! [`crate::folders::FolderRecord`]).
//!
//! Scenarios, in the words of someone organising their library by folder:
//!
//! * Given a folder of the library, when I give it a colour label, its row in the folder tree
//! shows that label; the folders around it show none.
//! * However the folder is spelled, it is one folder with one label.
//! * When I take the label off, the library keeps nothing about the folder.
//! * Every change is one undo step: undo puts back the label the folder had before.
//! * A path that names no folder is refused and nothing changes.
//! * When the folder is renamed or moved, its label and those of the folders inside it go along;
//! a folder beside it whose name merely starts the same way keeps its own. Undo brings them
//! back, also a label that was left at the destination.
//! * Labels survive closing and reopening the library.
use crate::*;
fn add(c: &mut Catalog, path: &str) {
let id = c.alloc_photo_id();
let name = path.rsplit('/').next().unwrap_or(path);
let p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
fn library() -> Catalog {
let mut c = Catalog::new();
for p in ["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip2/c.jpg", "/pics/home/d.jpg"] {
add(&mut c, p);
}
c
}
fn label(c: &mut Catalog, path: &str, l: Option<ColorLabel>) -> Op {
let op = c.folder_label_op(path, l);
c.apply(op).unwrap()
}
/// The label of each row of the tree, by path, depth first (rows without one left out).
fn tree_labels(c: &Catalog) -> Vec<(String, ColorLabel)> {
fn walk(n: &FolderNode, out: &mut Vec<(String, ColorLabel)>) {
if let Some(l) = n.label {
out.push((n.path.clone(), l));
}
n.children.iter().for_each(|c| walk(c, out));
}
let mut out = Vec::new();
c.folder_tree().iter().for_each(|n| walk(n, &mut out));
out
}
#[test]
fn a_new_library_knows_nothing_about_its_folders() {
let c = library();
assert_eq!(c.folder_record("/pics/trip"), None);
assert_eq!(c.folder_color_label("/pics/trip"), None);
assert!(tree_labels(&c).is_empty());
}
#[test]
fn a_labelled_folder_shows_its_label_in_the_tree_and_only_there() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Red));
assert_eq!(tree_labels(&c), vec![("/pics/trip".to_string(), ColorLabel::Red)]);
}
#[test]
fn however_a_folder_is_spelled_it_has_one_label() {
let mut c = library();
label(&mut c, "/pics//trip/", Some(ColorLabel::Green));
assert_eq!(c.folder_color_label("/pics/./trip"), Some(ColorLabel::Green));
label(&mut c, "/pics/home/../trip", Some(ColorLabel::Blue));
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Blue));
assert_eq!(tree_labels(&c).len(), 1, "one folder, one record");
}
#[test]
fn taking_the_label_off_leaves_no_record() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Purple));
label(&mut c, "/pics/trip", None);
assert_eq!(c.folder_record("/pics/trip"), None);
assert!(!c.to_snapshot().contains("folder_records"), "nothing empty is saved");
}
#[test]
fn an_empty_record_is_no_record() {
let mut c = library();
c.apply(Op::SetFolderRecord { folder: "/pics/trip".into(), record: Some(FolderRecord::default()) }).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None);
}
#[test]
fn undo_puts_back_the_label_the_folder_had() {
let mut c = library();
let undo_first = label(&mut c, "/pics/trip", Some(ColorLabel::Yellow));
let undo_second = label(&mut c, "/pics/trip", Some(ColorLabel::Red));
c.apply(undo_second).unwrap();
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
c.apply(undo_first).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None);
}
#[test]
fn a_path_that_names_no_folder_is_refused() {
let mut c = library();
let before = c.to_snapshot();
for bad in ["", "relative/trip", ".", "a/.."] {
let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) };
assert!(c.apply(op).is_err(), "{bad:?} names no folder");
}
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn a_moved_folder_takes_its_labels_and_its_subfolders_labels_along() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
label(&mut c, "/pics/trip2", Some(ColorLabel::Blue));
let ops = c.folder_records_follow("/pics/trip", "/archive/2026/trip");
c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None);
assert_eq!(c.folder_color_label("/archive/2026/trip"), Some(ColorLabel::Red));
assert_eq!(c.folder_color_label("/archive/2026/trip/day1"), Some(ColorLabel::Green));
assert_eq!(c.folder_color_label("/pics/trip2"), Some(ColorLabel::Blue), "a neighbour that only starts the same stays");
}
#[test]
fn undoing_a_move_brings_back_every_label_also_one_left_at_the_destination() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
// a folder that once was at the destination left its labels behind
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
label(&mut c, "/pics/holiday/day9", Some(ColorLabel::Yellow));
let before = c.to_snapshot();
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
let undo = c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Red), "the moved folder's label wins");
assert_eq!(c.folder_record("/pics/holiday/day9"), None, "what was known about the old folder goes");
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn a_folder_without_labels_moves_without_ops() {
let c = library();
assert!(c.folder_records_follow("/pics/trip", "/elsewhere/trip").is_empty());
}
#[test]
fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() {
let mut c = library();
// a folder that was at the destination once (its photos are gone) left its label behind
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
let undo = c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_record("/pics/holiday"), None, "the folder now there is another one");
c.apply(undo).unwrap();
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple));
}
#[test]
fn labels_survive_the_journal_without_a_snapshot() {
let m = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
{
let p = "/pics/trip/a.jpg";
let id = c.alloc_photo_id();
let op = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: p.into() }, "a.jpg", "JPEG", 6, 4, "2026-01-01")) };
c.apply(op.clone()).unwrap();
j.append(std::slice::from_ref(&op)).unwrap();
}
let op = c.folder_label_op("/pics/trip", Some(ColorLabel::Yellow));
c.apply(op.clone()).unwrap();
j.append(std::slice::from_ref(&op)).unwrap();
drop(j);
let (_, reopened, r) = Journal::open(Box::new(m)).unwrap();
assert!(r.replayed >= 2, "{r:?}");
assert_eq!(reopened.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
}
#[test]
fn labels_survive_closing_and_reopening_the_library() {
let mut c = library();
label(&mut c, "/pics/home", Some(ColorLabel::Green));
let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
assert_eq!(reopened.folder_color_label("/pics/home"), Some(ColorLabel::Green));
}
#[test]
fn a_library_saved_before_folder_records_opens_without_any() {
let c = library();
let old = c.to_snapshot();
assert!(!old.contains("folder_records"));
let reopened = Catalog::from_snapshot(&old).unwrap();
assert_eq!(reopened.folder_record("/pics/trip"), None);
}
+22 -3
View File
@@ -47,7 +47,7 @@ fn v1_library_loads_and_is_upgraded() {
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3.
/// A library saved before albums could be ordered by hand opens as it was and is rewritten in the current format.
#[test]
fn v2_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
@@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() {
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(2)));
assert_eq!(snapshot_version(&m), 3);
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A library saved before keywords could be listed on their own opens as it was and is rewritten
/// in the current format.
#[test]
fn v3_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
m.set(LOG, log.into_bytes());
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(3)));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
#[test]
@@ -193,8 +207,13 @@ fn op_variants_are_versioned() {
| Op::Batch { .. } => 1,
Op::SetBrowsed { .. } => 2,
Op::SetAlbumOrder { .. } => 3,
Op::SetEmbeddedLens { .. } => 4,
Op::SetKeyword { .. } => 5,
Op::SetFolderRecord { .. } => 6,
Op::SetAsShotWb { .. } => 7,
Op::SetHistoryCursor { .. } => 8,
}
}
let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None });
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
+50
View File
@@ -78,6 +78,15 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) },
9 if b % 7 == 5 => Op::SetKeyword {
path: format!("travel|place {}", b % 4),
info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo {
person: a.is_multiple_of(2),
synonyms: vec![format!("trip {a}")],
..Default::default()
}),
},
9 if b % 11 == 4 => c.folder_label_op(&format!("/p/f{}", a % 4), ColorLabel::ALL.get(usize::from(b % 6)).copied()),
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
9 if b.is_multiple_of(2) => {
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
@@ -326,3 +335,44 @@ fn failed_streamed_write_keeps_the_old_file() {
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
/// A damaged rendering process in a saved photo (snapshot or log) reads as V1 and costs nothing
/// else: the photo and its edits load (`docs/process-versions.md`).
#[test]
fn a_damaged_process_value_loads_as_v1_with_the_edits() {
use lightcraft_develop::ProcessVersion;
let mut c = Catalog::new();
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
p.develop = Arc::new(DevelopSettings {
process: ProcessVersion(7),
light: lightcraft_develop::Light { exposure: 0.5, ..Default::default() },
..Default::default()
});
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let snapshot = c.to_snapshot();
assert!(snapshot.contains(r#""process":7"#));
let snapshot = format!(
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":0,\"catalog\":{}}}\n",
crate::journal::VERSION,
snapshot.replace(r#""process":7"#, r#""process":-1"#)
);
let m = MemStore::new();
m.set(SNAPSHOT, snapshot.into_bytes());
let (_, loaded, _) = open(&m);
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5));
// and in a (CRC-valid) log record
let edit = DevelopSettings {
process: ProcessVersion(7),
light: lightcraft_develop::Light { exposure: 0.8, ..Default::default() },
..Default::default()
};
let op = Op::SetDevelop { id, settings: Arc::new(edit), label: "Edit".into(), edited: None };
let body = serde_json::to_string(&op).unwrap().replace(r#""process":7"#, r#""process":"seven""#);
assert!(body.contains("seven"));
m.set(LOG, format!("{{\"seq\":1,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
let (_, loaded, _) = open(&m);
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
+79
View File
@@ -5,6 +5,17 @@ use std::path::PathBuf;
use crate::lock::{LOCK, OWNER};
use crate::*;
/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of
/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file
/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock
/// another test drops in that moment stays held by the copy, so acquiring it again could fail with
/// "in use".
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@@ -14,6 +25,7 @@ fn temp_dir(tag: &str) -> PathBuf {
#[test]
fn second_opener_is_refused_until_the_first_lets_go() {
let _serial = serial();
let dir = temp_dir("twice");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
assert!(first.held());
@@ -38,6 +50,7 @@ fn second_opener_is_refused_until_the_first_lets_go() {
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
#[test]
fn leftover_lock_files_are_not_a_lock() {
let _serial = serial();
let dir = temp_dir("stale");
std::fs::write(dir.join(LOCK), b"").unwrap();
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
@@ -52,6 +65,7 @@ fn leftover_lock_files_are_not_a_lock() {
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
#[test]
fn lock_held_by_another_process_is_released_when_it_dies() {
let _serial = serial();
let dir = temp_dir("child");
let exe = std::env::current_exe().unwrap();
let mut child = std::process::Command::new(exe)
@@ -91,3 +105,68 @@ fn lock_holder_child() {
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
std::thread::sleep(std::time::Duration::from_secs(120));
}
/// Issue #680: a process spawned by any thread holds a copy of every descriptor until it execs
/// (close-on-exec only closes them then), and `flock` lasts while any copy of the descriptor is
/// open. Dropping the `LibraryLock` must still free the library, so it releases explicitly rather
/// than relying on its handle closing. A `try_clone` of the descriptor stands in for the child's
/// copy: it refers to the same open file description, which is exactly what the child gets.
#[cfg(unix)]
#[test]
fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
let dir = temp_dir("copy");
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
let copy = first.descriptor_copy().unwrap();
drop(first);
let again = LibraryLock::acquire(&dir, "lightcraft-cli").expect("the library is free once its LibraryLock is dropped");
assert!(again.held());
drop((again, copy));
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
#[cfg(unix)]
#[test]
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let dir = temp_dir("spawn");
let stop = Arc::new(AtomicBool::new(false));
let spawner = {
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
}
}
spawned
})
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let mut cycles = 0usize;
let mut refused = Vec::new();
while cycles < 200_000 && std::time::Instant::now() < deadline {
match LibraryLock::acquire(&dir, "LightCraft") {
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
}
cycles += 1;
}
stop.store(true, Ordering::Relaxed);
let spawned = spawner.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(spawned > 0, "the spawning thread never got a child off the ground");
assert!(
refused.is_empty(),
"{} of {cycles} re-acquires refused while {spawned} children were spawned: {:?}",
refused.len(),
&refused[..refused.len().min(3)]
);
}
+11 -2
View File
@@ -30,17 +30,26 @@ jxl-oxide = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ravif = { workspace = true, optional = true }
rav1e = { workspace = true, optional = true }
avif-serialize = { workspace = true, optional = true }
# spawns threads of its own (std::thread), which wasm32 has not got
jixel = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
jxl = ["dep:jxl-oxide"]
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
jxl = ["dep:jxl-oxide", "dep:jixel"]
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
heif = ["dep:lightcraft-heif"]
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
avif = ["dep:ravif"]
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
# JPEG XL tests read encoded samples without our colour conversion
jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+18 -7
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
@@ -23,6 +23,13 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
encode_webp_lossless(&img, &meta)?;
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?;
let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's
gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?;
```
Thumbnail fallback decoding limits the source to 64 million pixels by default.
@@ -45,9 +52,10 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
| GIF / BMP | yes (first GIF frame) | — | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
| 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_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 |
## Colour
@@ -80,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
@@ -100,9 +109,11 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap
## Gaps / limitations
- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
(dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
`Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
- **AVIF decode**: no permissively licensed, pure-Rust AV1 decoder exists today (dav1d is C;
rav1d-safe is AGPL). Files are sniffed and rejected with `Error::Unsupported`.
- **HEIC** needs the `heif` feature (off by default: HEVC patents are the distributor's call);
without it, `Error::Unsupported` says the build has no HEIC support. 4:2:2 and 4:4:4 HEIF
(Canon/Sony HIF) is refused: heic-rs 0.1.1 loses sync on those bitstreams.
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
baseline) for maximum compatibility.
+442
View File
@@ -0,0 +1,442 @@
//! Monitor (display) ICC profiles: showing photos correctly on wide-gamut and calibrated displays.
//!
//! The loupe renders straight into the display's own primaries: the develop pipeline targets the
//! display's linear RGB ([`DisplayProfile::to_rec2020`]) and encodes it with the sRGB curve, as it
//! does for every 8-bit output. That "proxy" encoding then goes to the display's real device
//! values with [`DisplayProfile::correct`]: its tone curves for a matrix/TRC profile (a per-channel
//! table), or its LUTs for a LUT-based profile (a CMS transform). Images made for sRGB (thumbnails,
//! cached previews, embedded camera JPEGs) go through [`DisplayProfile::from_srgb`] instead.
//!
//! All conversions are relative colorimetric: the display's white is the image's white.
use std::sync::Arc;
use crate::icc;
use crate::space::Trc;
use lightcraft_color::{D50, D65, Mat3, REC2020, bradford};
use lightcraft_raster::Rgba8;
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions, Xyzd};
/// Largest profile file accepted (monitor profiles are a few KB; LUT profiles up to a few MB).
pub const MAX_PROFILE_BYTES: usize = 32 << 20;
/// How a display profile describes the display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayKind {
/// Primaries + tone curves (exact).
MatrixTrc,
/// LUT-based (A2B/B2A tables): applied through the CMS; the primaries the pipeline targets are
/// the profile's matrix tags when it has them, else measured through its tables.
Lut,
}
impl DisplayKind {
pub fn label(self) -> &'static str {
match self {
DisplayKind::MatrixTrc => "matrix",
DisplayKind::Lut => "lut",
}
}
}
/// The proxy → device step of a display.
enum Correction {
/// The proxy encoding is the display's (sRGB-curve matrix profile): nothing to do.
None,
/// Per-channel tables (matrix/TRC profile: same primaries, other curves).
Curves(Box<[[u8; 256]; 3]>),
/// A full CMS transform (LUT-based profile).
Cms(Arc<Transform8BitExecutor>),
}
/// A parsed display profile with its transforms (built once, applied to every preview).
pub struct DisplayProfile {
/// The profile's own description (or "Display profile").
pub description: String,
/// Identifies the profile (hash of its bytes): part of render keys.
pub id: u64,
pub kind: DisplayKind,
/// Linear display RGB → linear Rec.2020 (D65; display white → (1, 1, 1)).
pub to_rec2020: Mat3,
correction: Correction,
from_srgb: Arc<Transform8BitExecutor>,
to_srgb: Arc<Transform8BitExecutor>,
}
impl std::fmt::Debug for DisplayProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DisplayProfile")
.field("description", &self.description)
.field("id", &self.id)
.field("kind", &self.kind)
.field("to_rec2020", &self.to_rec2020)
.finish_non_exhaustive()
}
}
fn opts() -> TransformOptions {
TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() }
}
fn xyzd(v: [f64; 3]) -> Xyzd {
Xyzd { x: v[0], y: v[1], z: v[2] }
}
/// Linear Rec.2020 → XYZ relative to D50 (the ICC PCS), Bradford-adapted.
fn rec2020_to_xyz_d50() -> Mat3 {
bradford(D65, D50).mul(&REC2020.to_xyz())
}
/// Scale `m`'s columns so that (1, 1, 1) maps to (1, 1, 1): the display's white is the image's.
fn white_balanced(m: Mat3) -> Option<Mat3> {
let s = m.inverse()?.apply([1.0; 3]);
s.iter().all(|v| v.is_finite() && *v > 0.0).then(|| m.mul(&Mat3::diag(s[0], s[1], s[2])))
}
fn hash(bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish() | 1
}
/// Runs a moxcms call that may panic on a hostile profile.
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
}
impl DisplayProfile {
/// Parse a display profile (an RGB ICC profile, matrix/TRC or LUT-based) and build its
/// transforms. Errors say what is wrong with the file; nothing here panics.
pub fn from_icc(bytes: &[u8]) -> Result<DisplayProfile, String> {
if bytes.len() > MAX_PROFILE_BYTES {
return Err(format!("the profile is too large ({} MB)", bytes.len() >> 20));
}
let p = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
if p.color_space != DataColorSpace::Rgb {
return Err("not an RGB profile (a display profile describes an RGB device)".into());
}
if !matches!(p.pcs, DataColorSpace::Xyz | DataColorSpace::Lab) {
return Err("unsupported profile connection space".into());
}
let info = icc::parse(bytes).ok_or("not a valid ICC profile")?;
let description = info
.description
.as_deref()
.map(|d| d.trim_start_matches('\u{feff}').trim())
.filter(|d| !d.is_empty())
.unwrap_or("Display profile")
.to_string();
let has_lut = p.lut_a_to_b_colorimetric.is_some()
|| p.lut_a_to_b_perceptual.is_some()
|| p.lut_b_to_a_colorimetric.is_some()
|| p.lut_b_to_a_perceptual.is_some();
let srgb = ColorProfile::new_srgb();
// the primaries the pipeline renders into
let (kind, to_xyz_d50, curves) = match &info.kind {
icc::IccKind::MatrixTrc { to_xyz_d50, trc } if !has_lut => (DisplayKind::MatrixTrc, *to_xyz_d50, Some(trc.clone())),
icc::IccKind::MatrixTrc { to_xyz_d50, .. } => (DisplayKind::Lut, *to_xyz_d50, None),
_ => (DisplayKind::Lut, measured_to_xyz_d50(&p)?, None),
};
let to_rec2020 =
rec2020_to_xyz_d50().inverse().map(|m| m.mul(&to_xyz_d50)).and_then(white_balanced).ok_or("the profile's primaries are degenerate")?;
if to_rec2020.0.iter().flatten().any(|v| !v.is_finite()) {
return Err("the profile's primaries are degenerate".into());
}
// the proxy: the display's primaries (as the pipeline targets them), sRGB curve
let proxy_xyz = rec2020_to_xyz_d50().mul(&to_rec2020).0;
let mut proxy = ColorProfile::new_srgb();
proxy.cicp = None;
proxy.red_colorant = xyzd([proxy_xyz[0][0], proxy_xyz[1][0], proxy_xyz[2][0]]);
proxy.green_colorant = xyzd([proxy_xyz[0][1], proxy_xyz[1][1], proxy_xyz[2][1]]);
proxy.blue_colorant = xyzd([proxy_xyz[0][2], proxy_xyz[1][2], proxy_xyz[2][2]]);
let transform = |src: &ColorProfile, dst: &ColorProfile| guarded(|| src.create_transform_8bit(Layout::Rgba, dst, Layout::Rgba, opts()).ok());
let correction = match curves {
Some(trc) if trc.iter().all(|t| t.approx_eq(&Trc::Srgb, 2e-4)) => Correction::None,
Some(trc) => Correction::Curves(Box::new(curve_tables(&trc))),
None => Correction::Cms(transform(&proxy, &p).ok_or("the profile's tables can't be applied")?),
};
let from_srgb = transform(&srgb, &p).ok_or("can't convert sRGB to this profile")?;
let to_srgb = transform(&proxy, &srgb).ok_or("can't convert this profile to sRGB")?;
Ok(DisplayProfile { description, id: hash(bytes), kind, to_rec2020, correction, from_srgb, to_srgb })
}
/// Does [`correct`](Self::correct) change anything (the display's curves aren't sRGB's)?
pub fn needs_correction(&self) -> bool {
!matches!(self.correction, Correction::None)
}
/// A render in the display's primaries with the sRGB curve (the pipeline's display target) →
/// the display's device values, in place.
pub fn correct(&self, img: &mut Rgba8) -> Result<(), String> {
match &self.correction {
Correction::None => Ok(()),
Correction::Curves(t) => {
let map = |p: &mut [u8; 4]| *p = [t[0][p[0] as usize], t[1][p[1] as usize], t[2][p[2] as usize], p[3]];
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
img.data.par_chunks_mut(1 << 14).for_each(|c| c.iter_mut().for_each(map));
}
#[cfg(not(feature = "parallel"))]
img.data.iter_mut().for_each(map);
Ok(())
}
Correction::Cms(x) => apply(x, img),
}
}
/// An sRGB image → the display's device values, in place.
pub fn from_srgb(&self, img: &mut Rgba8) -> Result<(), String> {
apply(&self.from_srgb, img)
}
/// A render in the display's primaries with the sRGB curve → sRGB (colours outside sRGB are
/// clipped): for histograms and cached previews, which stay sRGB.
pub fn to_srgb(&self, img: &Rgba8) -> Result<Rgba8, String> {
let mut out = img.clone();
apply(&self.to_srgb, &mut out)?;
Ok(out)
}
}
/// Device RGB → PCS (XYZ D50) of the pure primaries, measured through the profile's tables
/// (LUT-only profiles have no colorant tags).
fn measured_to_xyz_d50(p: &ColorProfile) -> Result<Mat3, String> {
let lin = icc::linear_rec2020_profile();
let x = guarded(|| p.create_transform_f32(Layout::Rgb, &lin, Layout::Rgb, opts()).ok()).ok_or("the profile's tables can't be applied")?;
let src = [1.0f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
let mut dst = [0f32; 9];
guarded(|| x.transform(&src, &mut dst).ok()).ok_or("the profile's tables can't be applied")?;
let c = |i: usize| [dst[i] as f64, dst[i + 3] as f64, dst[i + 6] as f64];
// columns: each primary in linear Rec.2020
let m = Mat3([c(0), c(1), c(2)]);
if m.0.iter().flatten().any(|v| !v.is_finite()) || m.determinant().abs() < 1e-6 {
return Err("the profile's primaries are degenerate".into());
}
Ok(rec2020_to_xyz_d50().mul(&m))
}
/// sRGB-encoded 8-bit value → the display curve's code value, per channel.
fn curve_tables(trc: &[Trc; 3]) -> [[u8; 256]; 3] {
let mut t = [[0u8; 256]; 3];
for (k, tab) in t.iter_mut().enumerate() {
for (i, v) in tab.iter_mut().enumerate() {
let lin = lightcraft_color::transfer::srgb_to_linear(i as f32 / 255.0);
*v = (from_linear(&trc[k], lin) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
}
}
t
}
/// Inverse of [`Trc::to_linear`] on 0..1 (bisection: works for tables and every parametric form).
fn from_linear(t: &Trc, y: f32) -> f32 {
let y = y.clamp(0.0, 1.0);
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if t.to_linear(mid) < y {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
fn apply(x: &Arc<Transform8BitExecutor>, img: &mut Rgba8) -> Result<(), String> {
const CHUNK: usize = 1 << 14; // pixels
let src = img.data.clone();
let run = |s: &[[u8; 4]], d: &mut [[u8; 4]]| guarded(|| x.transform(s.as_flattened(), d.as_flattened_mut()).ok()).is_some();
#[cfg(feature = "parallel")]
let ok = {
use rayon::prelude::*;
src.par_chunks(CHUNK).zip(img.data.par_chunks_mut(CHUNK)).all(|(s, d)| run(s, d))
};
#[cfg(not(feature = "parallel"))]
let ok = src.chunks(CHUNK).zip(img.data.chunks_mut(CHUNK)).all(|(s, d)| run(s, d));
if ok { Ok(()) } else { Err("display profile transform failed".into()) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::space::NamedSpace;
use lightcraft_color::{DISPLAY_P3, SRGB};
fn img(px: &[[u8; 4]]) -> Rgba8 {
Rgba8 { width: px.len(), height: 1, data: px.to_vec() }
}
fn close(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
(0..4).all(|i| (a[i] as i32 - b[i] as i32).abs() <= tol)
}
#[test]
fn srgb_display_changes_nothing() {
let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::Srgb)).unwrap();
assert_eq!(d.kind, DisplayKind::MatrixTrc);
assert!(!d.needs_correction());
for (i, row) in d.to_rec2020.0.iter().enumerate() {
let want = SRGB.to_space(&REC2020).0[i];
for k in 0..3 {
assert!((row[k] - want[k]).abs() < 2e-3, "{:?}", d.to_rec2020);
}
}
let px = [[0, 0, 0, 255], [255, 255, 255, 255], [200, 30, 90, 128], [17, 140, 251, 7]];
let mut a = img(&px);
d.correct(&mut a).unwrap();
assert_eq!(a.data, px);
let mut b = img(&px);
d.from_srgb(&mut b).unwrap();
for (x, y) in b.data.iter().zip(&px) {
assert!(close(*x, *y, 1), "{x:?} {y:?}");
}
}
/// On a Display P3 monitor, sRGB red is shown with less red drive than full; P3 green (out of
/// sRGB) is reachable in the display's own primaries.
#[test]
fn p3_display() {
let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
assert!(!d.needs_correction(), "P3 uses the sRGB curve");
let want = DISPLAY_P3.to_space(&REC2020).0;
for i in 0..3 {
for k in 0..3 {
assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 2e-3, "{:?}", d.to_rec2020);
}
}
let mut red = img(&[[255, 0, 0, 255], [128, 128, 128, 255]]);
d.from_srgb(&mut red).unwrap();
assert!(red.data[0][0] < 245 && red.data[0][1] > 40, "{:?}", red.data[0]);
assert!(close(red.data[1], [128, 128, 128, 255], 1), "grey stays grey: {:?}", red.data[1]);
// the display's own pure green is outside sRGB: it clips there
let srgb = d.to_srgb(&img(&[[0, 255, 0, 255]])).unwrap();
assert!(srgb.data[0][0] < 10 && srgb.data[0][1] == 255, "{:?}", srgb.data[0]);
}
/// A gamma 2.2 display (Adobe RGB-like): the proxy's sRGB curve goes to the display's curve.
#[test]
fn gamma_display_uses_its_curve() {
let bytes = icc::write_matrix_trc(&lightcraft_color::ADOBE_RGB, &Trc::Gamma(2.2));
let d = DisplayProfile::from_icc(&bytes).unwrap();
assert!(d.needs_correction());
let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [10, 128, 240, 9]]);
d.correct(&mut a).unwrap();
assert_eq!(a.data[0], [0, 0, 0, 255]);
assert_eq!(a.data[1], [255, 255, 255, 255]);
for (k, v) in [10u8, 128, 240].into_iter().enumerate() {
let lin = lightcraft_color::transfer::srgb_to_linear(v as f32 / 255.0);
let want = (lin.powf(1.0 / 2.2) * 255.0).round() as i32;
assert!((a.data[2][k] as i32 - want).abs() <= 1, "{k}: {} vs {want}", a.data[2][k]);
}
assert_eq!(a.data[2][3], 9, "alpha kept");
}
#[test]
fn rejects_bad_profiles() {
assert!(DisplayProfile::from_icc(&[]).is_err());
assert!(DisplayProfile::from_icc(&[0u8; 300]).is_err());
let mut gray = ColorProfile::new_gray_with_gamma(2.2);
gray.cicp = None;
assert!(DisplayProfile::from_icc(&gray.encode().unwrap()).is_err());
// truncated / corrupted real profiles never panic
let good = icc::write_named(NamedSpace::DisplayP3);
for n in [10, 128, 140, 200, good.len() - 1] {
let _ = DisplayProfile::from_icc(&good[..n]);
}
for i in (0..good.len()).step_by(7) {
let mut b = good.clone();
b[i] ^= 0xa5;
let _ = DisplayProfile::from_icc(&b);
}
}
/// A LUT-based display profile with no colorant or TRC tags (its A2B/B2A tables describe a
/// Display P3 panel with a gamma 2.2 response): the primaries are measured through the tables
/// and every preview goes through the CMS.
fn lut_only_p3_gamma22() -> Vec<u8> {
use moxcms::{LutMultidimensionalType, LutWarehouse, Matrix3d, ProfileClass, ToneReprCurve, Vector3d};
let m = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).0;
let inv = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).inverse().unwrap().0;
let curves = |v: Vec<f32>| vec![ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v)];
let ident = || curves(vec![1.0]);
// mAB: A → CLUT → M → matrix → B (no CLUT: M curves linearize, the matrix goes to XYZ)
let a2b = LutWarehouse::Multidimensional(LutMultidimensionalType {
num_input_channels: 3,
num_output_channels: 3,
grid_points: [0; 16],
clut: None,
a_curves: vec![],
m_curves: curves(vec![2.2]),
matrix: Matrix3d { v: m.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
b_curves: ident(),
bias: Vector3d::default(),
});
let b2a = LutWarehouse::Multidimensional(LutMultidimensionalType {
num_input_channels: 3,
num_output_channels: 3,
grid_points: [0; 16],
clut: None,
a_curves: vec![],
m_curves: curves(vec![1.0 / 2.2]),
matrix: Matrix3d { v: inv.map(|r| r.map(|v| v * 65535.0 / 32768.0)) },
b_curves: ident(),
bias: Vector3d::default(),
});
let mut p = ColorProfile::new_srgb();
p.cicp = None;
p.profile_class = ProfileClass::DisplayDevice;
p.red_colorant = Xyzd::default();
p.green_colorant = Xyzd::default();
p.blue_colorant = Xyzd::default();
p.red_trc = None;
p.green_trc = None;
p.blue_trc = None;
p.lut_a_to_b_colorimetric = Some(a2b.clone());
p.lut_a_to_b_perceptual = Some(a2b);
p.lut_b_to_a_colorimetric = Some(b2a.clone());
p.lut_b_to_a_perceptual = Some(b2a);
p.encode().unwrap()
}
#[test]
fn lut_profile_goes_through_the_cms() {
let bytes = lut_only_p3_gamma22();
let parsed = ColorProfile::new_from_slice(&bytes).unwrap();
assert!(!parsed.is_matrix_shaper() && parsed.lut_b_to_a_colorimetric.is_some(), "a LUT-only profile");
let d = DisplayProfile::from_icc(&bytes).unwrap();
assert_eq!(d.kind, DisplayKind::Lut);
assert!(d.needs_correction());
// the measured primaries are P3's
let want = DISPLAY_P3.to_space(&REC2020).0;
for i in 0..3 {
for k in 0..3 {
assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 0.02, "{:?} vs {want:?}", d.to_rec2020);
}
}
// the correction is the gamma 2.2 curve; grey stays grey
let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 200]]);
d.correct(&mut a).unwrap();
assert!(close(a.data[0], [0, 0, 0, 255], 1) && close(a.data[1], [255, 255, 255, 255], 1), "{:?}", a.data);
let lin = lightcraft_color::transfer::srgb_to_linear(128.0 / 255.0);
let g = (lin.powf(1.0 / 2.2) * 255.0).round() as u8;
assert!(close(a.data[2], [g, g, g, 200], 2), "{:?} vs {g}", a.data[2]);
// sRGB red → less than full red drive on a P3 panel
let mut red = img(&[[255, 0, 0, 255]]);
d.from_srgb(&mut red).unwrap();
assert!(red.data[0][0] < 245 && red.data[0][1] > 30, "{:?}", red.data[0]);
}
#[test]
fn ids_identify_profiles() {
let a = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
let b = DisplayProfile::from_icc(&icc::write_named(NamedSpace::AdobeRgb)).unwrap();
let a2 = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
assert_ne!(a.id, b.id);
assert_eq!(a.id, a2.id);
}
}
+316 -33
View File
@@ -1,6 +1,6 @@
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
//! embedded where the format allows.
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and,
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
use crate::{Error, Result};
use lightcraft_raster::Rgba8;
@@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> {
EncodeImage { width, height, channels, samples }
}
fn validate(&self) -> Result<()> {
pub(crate) fn validate(&self) -> Result<()> {
if !(1..=4).contains(&self.channels) {
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
}
@@ -79,6 +79,46 @@ pub enum ChromaSubsampling {
const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF,
/// XMP and the ICC profile in chunks, in that order.
pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result<Vec<(u8, Vec<u8>)>> {
let mut segs: Vec<(u8, Vec<u8>)> = Vec::new();
let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
let [hi, lo] = p.to_be_bytes();
[1, hi, lo, hi, lo]
});
segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
if let Some(exif) = meta.exif {
if exif.len() > 65_527 {
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
}
segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
}
if let Some(xmp) = meta.xmp {
segs.push((0xE1, xmp_segment(xmp)?));
}
if let Some(icc) = meta.icc {
// ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
if chunks.len() > 255 {
return Err(Error::Encode("ICC profile too large for JPEG".into()));
}
for (i, c) in chunks.iter().enumerate() {
segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
}
}
Ok(segs)
}
/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment.
pub(crate) fn xmp_segment(xmp: &str) -> Result<Vec<u8>> {
let seg = [XMP_NS, xmp.as_bytes()].concat();
if seg.len() > 65_533 {
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
}
Ok(seg)
}
/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100.
pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
@@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
};
if subsampling != ChromaSubsampling::S422 {
// Parallel encoder (restart-interval bands on all cores).
let mut segs: Vec<(u8, Vec<u8>)> = Vec::new();
let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
let [hi, lo] = p.to_be_bytes();
[1, hi, lo, hi, lo]
});
segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
if let Some(exif) = meta.exif {
if exif.len() > 65_527 {
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
}
segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
}
if let Some(xmp) = meta.xmp {
let seg = [XMP_NS, xmp.as_bytes()].concat();
if seg.len() > 65_533 {
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
}
segs.push((0xE1, seg));
}
if let Some(icc) = meta.icc {
// ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
if chunks.len() > 255 {
return Err(Error::Encode("ICC profile too large for JPEG".into()));
}
for (i, c) in chunks.iter().enumerate() {
segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
}
}
let segs = jpeg_app_segments(meta)?;
let ch = if img.channels == 2 { 1 } else { img.channels as usize };
// `data` is already gray for 2-channel input (see above)
return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs));
@@ -279,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
Ok(cur.into_inner())
}
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
crate::psd::encode(img, meta)
}
/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container.
pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
@@ -351,6 +369,237 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
}
}
/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m².
pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1.
pub fn pq_encode(nits: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
}
/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m².
pub fn pq_decode(e: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let p = e.clamp(0.0, 1.0).powf(1.0 / M2);
((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
/// `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_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) {
return Err(Error::Encode("HDR AVIF: sample buffer too short".into()));
}
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
{
use rav1e::prelude::*;
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL
let mut max_nits = 0f32;
let mut sum_nits = 0f64;
let planes: Vec<[u16; 3]> = rgb
.as_chunks::<3>()
.0
.iter()
.take(n)
.map(|c| {
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
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] = 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;
[y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16)
})
.collect();
let q = quality.clamp(1, 100) as f32 / 100.0;
// ravif's quality → quantizer curve, so qualities match the SDR AVIF path
let x = if q >= 0.82 {
(1.0 - q) * 2.6
} else if q > 0.25 {
1.0 - 0.125 - q * 0.5
} else {
1.0 - q
};
let quantizer = (x * 255.0).round() as usize;
let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10));
ec.width = w;
ec.height = h;
ec.time_base = Rational::new(1, 1);
ec.bit_depth = 10;
ec.chroma_sampling = ChromaSampling::Cs444;
ec.pixel_range = PixelRange::Full;
ec.color_description = Some(ColorDescription {
color_primaries: ColorPrimaries::BT2020,
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 =
(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;
ec.speed_settings.multiref = false;
ec.speed_settings.rdo_lookahead_frames = 1;
ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None;
let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}"));
let mut ctx: Context<u16> = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?;
let mut frame = ctx.new_frame();
{
let mut it = planes.iter();
let mut fp = frame.planes.iter_mut();
let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else {
return Err(Error::Encode("AV1: frame has no planes".into()));
};
let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default()));
for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) {
for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) {
let Some(p) = it.next() else { break };
(*y, *u, *v) = (p[0], p[1], p[2]);
}
}
}
ctx.send_frame(frame).map_err(|x| e(&x))?;
ctx.flush();
let mut av1 = Vec::new();
loop {
match ctx.receive_packet() {
Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data),
Ok(_) => {}
Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break,
Err(x) => return Err(e(&x)),
}
}
use avif_serialize::constants as c;
let mut a = avif_serialize::Aviffy::new();
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
.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);
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());
}
Ok(a.to_vec(&av1, None, width, height, 10))
}
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
{
let _ = (quality, speed, meta, transfer);
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}
/// Encode an HDR PNG: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
/// (`width × height × 3`), as for [`encode_avif_pq`]. Written as 16-bit RGB with the PQ curve
/// (SDR white at [`HDR_REFERENCE_WHITE_NITS`]), described by a `cICP` chunk (PNG third edition:
/// BT.2020 primaries, PQ, RGB, full range) and a `cLLI` content light level chunk (the same
/// levels the HDR AVIF records). EXIF and XMP are embedded; an ICC profile is not (`cICP`
/// describes the colour, and readers prefer it). Readers without `cICP` support show the PQ
/// samples as if they were sRGB (flat, washed out): expected for a PQ PNG, there is no fallback.
pub fn encode_png_pq(width: u32, height: u32, rgb: &[f32], meta: &EncodeMeta) -> Result<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
return Err(Error::Encode("HDR PNG: sample buffer too short".into()));
}
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 luminance
let mut max_nits = 0f32;
let mut sum_nits = 0f64;
let mut bytes = Vec::with_capacity(n * 6);
for c in rgb.as_chunks::<3>().0.iter().take(n) {
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += (K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]) as f64;
for v in nits {
bytes.extend_from_slice(&((pq_encode(v) * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes());
}
}
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u32, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u32);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.exif_metadata = meta.exif.map(|b| b.to_vec().into());
info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| {
let ppm = (p as f64 / 0.0254).round() as u32;
png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter }
});
let mut out = Vec::new();
let e = |e: png::EncodingError| Error::Encode(e.to_string());
{
let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?;
enc.set_compression(png::Compression::Fast);
if let Some(xmp) = meta.xmp {
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
}
let mut wr = enc.write_header().map_err(e)?;
// BT.2020 primaries (9), PQ (16), RGB (0), full range (1)
wr.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1]).map_err(e)?;
// MaxCLL and MaxFALL in units of 0.0001 cd/m²
let clli = [(cll.0 * 10_000).to_be_bytes(), (cll.1 * 10_000).to_be_bytes()].concat();
wr.write_chunk(png::chunk::ChunkType(*b"cLLI"), &clli).map_err(e)?;
wr.write_image_data(&bytes).map_err(e)?;
wr.finish().map_err(e)?;
}
Ok(out)
}
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
@@ -379,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());
}
}
+834
View File
@@ -0,0 +1,834 @@
//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to
//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays.
//!
//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear
//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit
//! codes), so quantisation in the base is handed back by the gain map.
//! - [`apply`] rebuilds the HDR rendition from a base and its gain map.
//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO
//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO
//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same
//! map (what survives an iMessage send).
//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom),
//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP.
//!
//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) − log2(sdr + offset_sdr)` as
//! `min + (max − min) · (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how
//! much headroom the display has between `base_headroom` and `alternate_headroom`.
use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment};
use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par};
/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated).
pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0";
/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output.
const DENOMINATOR: u32 = 1_000_000;
/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value
/// when the map is single-channel.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GainMapMeta {
/// Three gain channels (R, G, B) instead of one luminance gain.
pub multichannel: bool,
/// The gain applies in the base image's colour space.
pub use_base_colour_space: bool,
/// log2 headroom at which the base is shown as is (0: an SDR base).
pub base_headroom: f32,
/// log2 headroom at which the gain map applies in full.
pub alternate_headroom: f32,
pub min: [f32; 3],
pub max: [f32; 3],
pub gamma: [f32; 3],
pub base_offset: [f32; 3],
pub alternate_offset: [f32; 3],
}
impl Default for GainMapMeta {
fn default() -> Self {
GainMapMeta {
multichannel: false,
use_base_colour_space: true,
base_headroom: 0.0,
alternate_headroom: 1.0,
min: [0.0; 3],
max: [1.0; 3],
gamma: [1.0; 3],
base_offset: [1.0 / 64.0; 3],
alternate_offset: [1.0 / 64.0; 3],
}
}
}
impl GainMapMeta {
fn channels(&self) -> usize {
if self.multichannel { 3 } else { 1 }
}
/// Usable by [`apply`]: finite, positive gammas, max ≥ min, headrooms in order.
pub fn is_valid(&self) -> bool {
let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite());
fin(&self.min)
&& fin(&self.max)
&& fin(&self.gamma)
&& fin(&self.base_offset)
&& fin(&self.alternate_offset)
&& self.gamma.iter().all(|g| *g > 0.0)
&& self.min.iter().zip(&self.max).all(|(a, b)| b >= a)
&& self.base_headroom.is_finite()
&& self.alternate_headroom.is_finite()
&& self.alternate_headroom > self.base_headroom
}
/// The ISO 21496-1 APP2 payload of the gain map image, URN included.
pub fn to_iso(&self) -> Vec<u8> {
let mut p = ISO_URN.to_vec();
p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this
p.extend_from_slice(&0u16.to_be_bytes()); // writer_version
let mut flags = 0u8;
if self.multichannel {
flags |= FLAG_MULTICHANNEL;
}
if self.use_base_colour_space {
flags |= FLAG_BASE_COLOUR_SPACE;
}
p.push(flags);
let unsigned = |p: &mut Vec<u8>, v: f32| {
let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32;
p.extend_from_slice(&n.to_be_bytes());
p.extend_from_slice(&DENOMINATOR.to_be_bytes());
};
let signed = |p: &mut Vec<u8>, v: f32| {
let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32;
p.extend_from_slice(&n.to_be_bytes());
p.extend_from_slice(&DENOMINATOR.to_be_bytes());
};
unsigned(&mut p, self.base_headroom.max(0.0));
unsigned(&mut p, self.alternate_headroom.max(0.0));
for c in 0..self.channels() {
signed(&mut p, self.min[c]);
signed(&mut p, self.max[c]);
unsigned(&mut p, self.gamma[c]);
signed(&mut p, self.base_offset[c]);
signed(&mut p, self.alternate_offset[c]);
}
p
}
/// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only
/// marker a primary image carries, unknown versions, a base that is the HDR rendition, or
/// values no decoder could use.
pub fn from_iso(p: &[u8]) -> Option<GainMapMeta> {
let mut r = Reader { b: p, at: 0 };
let min_version = r.u16()?;
let _writer_version = r.u16()?;
if min_version != 0 {
return None;
}
let flags = r.u8()?;
if flags & FLAG_BACKWARD != 0 {
return None;
}
let multichannel = flags & FLAG_MULTICHANNEL != 0;
let common = flags & FLAG_COMMON_DENOMINATOR != 0;
let common_d = if common { r.u32()? } else { 0 };
let unsigned = |r: &mut Reader| -> Option<f32> {
let n = r.u32()?;
let d = if common { common_d } else { r.u32()? };
(d != 0).then(|| (n as f64 / d as f64) as f32)
};
let signed = |r: &mut Reader| -> Option<f32> {
let n = r.u32()? as i32;
let d = if common { common_d } else { r.u32()? };
(d != 0).then(|| (n as f64 / d as f64) as f32)
};
let mut m = GainMapMeta {
multichannel,
use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0,
base_headroom: unsigned(&mut r)?,
alternate_headroom: unsigned(&mut r)?,
..GainMapMeta::default()
};
for c in 0..m.channels() {
m.min[c] = signed(&mut r)?;
m.max[c] = signed(&mut r)?;
m.gamma[c] = unsigned(&mut r)?;
m.base_offset[c] = signed(&mut r)?;
m.alternate_offset[c] = signed(&mut r)?;
}
if !multichannel {
m.fill_from_first();
}
m.is_valid().then_some(m)
}
fn fill_from_first(&mut self) {
for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] {
a[1] = a[0];
a[2] = a[0];
}
}
/// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files
/// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists.
pub fn from_hdrgm_xmp(xmp: &str) -> Option<GainMapMeta> {
if !xmp.contains("hdrgm:") {
return None;
}
let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true")));
if base_rendition_hdr {
return None;
}
let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> {
let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) };
let f: Vec<f32> = v.iter().map(|s| s.trim().parse::<f32>().ok()).collect::<Option<_>>()?;
match f.as_slice() {
[a] => Some(([*a; 3], false)),
[a, b, c] => Some(([*a, *b, *c], true)),
_ => None,
}
};
let (max, m1) = get("GainMapMax", f32::NAN)?;
if max.iter().any(|v| v.is_nan()) {
return None; // the one required parameter
}
let (min, m2) = get("GainMapMin", 0.0)?;
let (gamma, m3) = get("Gamma", 1.0)?;
let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?;
let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?;
let (cap_min, _) = get("HDRCapacityMin", 0.0)?;
let max_gain = max.iter().copied().fold(f32::MIN, f32::max);
let (cap_max, _) = get("HDRCapacityMax", max_gain)?;
let m = GainMapMeta {
multichannel: m1 || m2 || m3 || m4 || m5,
use_base_colour_space: true,
base_headroom: cap_min[0].max(0.0),
alternate_headroom: cap_max[0],
min,
max,
gamma,
base_offset,
alternate_offset,
};
m.is_valid().then_some(m)
}
/// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which
/// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps).
pub fn to_gain_map_xmp(&self) -> String {
let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"];
let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset];
let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]);
let mut s = String::from(XMP_OPEN);
s.push_str(r#"<rdf:Description rdf:about="" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0""#);
s.push_str(&format!(r#" hdrgm:HDRCapacityMin="{}""#, num(self.base_headroom)));
s.push_str(&format!(r#" hdrgm:HDRCapacityMax="{}""#, num(self.alternate_headroom)));
s.push_str(r#" hdrgm:BaseRenditionIsHDR="False""#);
for (n, v) in names.iter().zip(values) {
if uniform(v) {
s.push_str(&format!(r#" hdrgm:{n}="{}""#, num(v[0])));
}
}
s.push('>');
// per-channel values are an rdf:Seq of three, not a comma list: readers parse each item
for (n, v) in names.iter().zip(values) {
if !uniform(v) {
s.push_str(&format!("<hdrgm:{n}><rdf:Seq>"));
for x in v {
s.push_str(&format!("<rdf:li>{}</rdf:li>", num(*x)));
}
s.push_str(&format!("</rdf:Seq></hdrgm:{n}>"));
}
}
s.push_str("</rdf:Description>");
s.push_str(&self.apple_description());
s.push_str(XMP_CLOSE);
s
}
/// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces).
fn apple_description(&self) -> String {
// 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels
let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 };
let mut s = String::from(
r#"<rdf:Description rdf:about="" xmlns:apdi="http://ns.apple.com/pixeldatainfo/1.0/" xmlns:HDRGainMap="http://ns.apple.com/HDRGainMap/1.0/" xmlns:HDRToneMap="http://ns.apple.com/HDRToneMap/1.0/">"#,
);
s.push_str("<apdi:AuxiliaryImageType>urn:com:apple:photo:2020:aux:hdrgainmap</apdi:AuxiliaryImageType>");
s.push_str(&format!("<apdi:NativeFormat>{format}</apdi:NativeFormat><apdi:StoredFormat>{format}</apdi:StoredFormat>"));
s.push_str("<HDRGainMap:HDRGainMapVersion>131072</HDRGainMap:HDRGainMapVersion>");
// linear, not log2: the headroom multiplier itself
s.push_str(&format!("<HDRGainMap:HDRGainMapHeadroom>{}</HDRGainMap:HDRGainMapHeadroom>", num(self.alternate_headroom.exp2())));
s.push_str(&format!("<HDRToneMap:AlternateHeadroom>{}</HDRToneMap:AlternateHeadroom>", num(self.alternate_headroom)));
s.push_str("<HDRToneMap:ChannelMetadata><rdf:Seq>");
for c in 0..self.channels() {
s.push_str(&format!(
r#"<rdf:li rdf:parseType="Resource"><HDRToneMap:GainMapMin>{}</HDRToneMap:GainMapMin><HDRToneMap:GainMapMax>{}</HDRToneMap:GainMapMax><HDRToneMap:Gamma>{}</HDRToneMap:Gamma><HDRToneMap:BaseOffset>{}</HDRToneMap:BaseOffset><HDRToneMap:AlternateOffset>{}</HDRToneMap:AlternateOffset></rdf:li>"#,
num(self.min[c]),
num(self.max[c]),
num(self.gamma[c]),
num(self.base_offset[c]),
num(self.alternate_offset[c])
));
}
s.push_str("</rdf:Seq></HDRToneMap:ChannelMetadata>");
s.push_str(&format!("<HDRToneMap:BaseHeadroom>{}</HDRToneMap:BaseHeadroom>", num(self.base_headroom)));
s.push_str("<HDRToneMap:BaseColorIsWorkingColor>True</HDRToneMap:BaseColorIsWorkingColor><HDRToneMap:Version>1</HDRToneMap:Version>");
s.push_str("</rdf:Description>");
s
}
}
const FLAG_MULTICHANNEL: u8 = 0x80;
const FLAG_BASE_COLOUR_SPACE: u8 = 0x40;
const FLAG_COMMON_DENOMINATOR: u8 = 0x08;
const FLAG_BACKWARD: u8 = 0x04;
const XMP_OPEN: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">";
const XMP_CLOSE: &str = "</rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";
/// A short decimal (up to 6 significant digits, no exponent for ordinary values).
fn num(v: f32) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{:.6}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() }
}
/// The values of `hdrgm:<name>`: an attribute (`name="v"`) or an element holding an `rdf:Seq`.
fn xmp_values(xmp: &str, name: &str) -> Option<Vec<String>> {
let attr = format!("hdrgm:{name}=");
if let Some(i) = xmp.find(&attr) {
let rest = xmp.get(i + attr.len()..)?;
let q = rest.chars().next()?;
if q == '"' || q == '\'' {
let body = rest.get(1..)?;
let end = body.find(q)?;
return Some(vec![body.get(..end)?.to_string()]);
}
}
let open = format!("<hdrgm:{name}>");
let close = format!("</hdrgm:{name}>");
let i = xmp.find(&open)?;
let body = xmp.get(i + open.len()..)?;
let body = body.get(..body.find(&close)?)?;
let mut out = Vec::new();
let mut rest = body;
while let Some(a) = rest.find("<rdf:li>") {
let after = rest.get(a + 8..)?;
let b = after.find("</rdf:li>")?;
out.push(after.get(..b)?.to_string());
rest = after.get(b..)?;
if out.len() > 3 {
return None;
}
}
if out.is_empty() {
// a plain element value
out.push(body.trim().to_string());
}
Some(out)
}
struct Reader<'a> {
b: &'a [u8],
at: usize,
}
impl Reader<'_> {
fn take<const N: usize>(&mut self) -> Option<[u8; N]> {
let s = self.b.get(self.at..self.at.checked_add(N)?)?;
self.at += N;
s.try_into().ok()
}
fn u8(&mut self) -> Option<u8> {
self.take::<1>().map(|a| a[0])
}
fn u16(&mut self) -> Option<u16> {
self.take().map(u16::from_be_bytes)
}
fn u32(&mut self) -> Option<u32> {
self.take().map(u32::from_be_bytes)
}
}
/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes.
#[derive(Clone, Debug, PartialEq)]
pub struct GainImage {
pub width: usize,
pub height: usize,
pub channels: usize,
pub data: Vec<u8>,
}
impl GainImage {
/// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5).
fn sample(&self, x: f32, y: f32, c: usize) -> f32 {
let (w, h) = (self.width, self.height);
let fx = (x - 0.5).clamp(0.0, (w - 1) as f32);
let fy = (y - 0.5).clamp(0.0, (h - 1) as f32);
let (x0, y0) = (fx as usize, fy as usize);
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32;
let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx;
let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx;
a + (b - a) * ty
}
}
/// How [`compute`] builds the map.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GainMapOptions {
/// Downscale factor of the map (1, 2 or 4).
pub scale: usize,
/// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single
/// channel, so this is off by default.
pub multichannel: bool,
pub gamma: f32,
/// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet).
pub offset: f32,
}
impl Default for GainMapOptions {
fn default() -> Self {
GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 }
}
}
/// Measure the gain map that turns `sdr` into `hdr` (both `width × height` linear RGB in the same
/// primaries; `luma` = that space's luminance weights).
pub fn compute(
sdr: &[[f32; 3]],
hdr: &[[f32; 3]],
width: usize,
height: usize,
luma: [f32; 3],
o: &GainMapOptions,
) -> Result<(GainImage, GainMapMeta)> {
let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
if n == 0 || sdr.len() < n || hdr.len() < n {
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
}
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
}
let s = o.scale;
let (gw, gh) = (width.div_ceil(s), height.div_ceil(s));
let ch = if o.multichannel { 3 } else { 1 };
let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2];
let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2();
// block means of the log gains
let mut gains = vec![0f32; gw * gh * ch];
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for gy in 0..gh {
for gx in 0..gw {
let mut acc = [0f32; 3];
let mut count = 0f32;
for yy in gy * s..((gy + 1) * s).min(height) {
for xx in gx * s..((gx + 1) * s).min(width) {
let i = yy * width + xx;
let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue };
if o.multichannel {
for c in 0..3 {
acc[c] += ratio(a[c], b[c]);
}
} else {
acc[0] += ratio(y(a), y(b));
}
count += 1.0;
}
}
for c in 0..ch {
let g = if count > 0.0 { acc[c] / count } else { 0.0 };
let g = if g.is_finite() { g } else { 0.0 };
gains[(gy * gw + gx) * ch + c] = g;
lo[c] = lo[c].min(g);
hi[c] = hi[c].max(g);
}
}
}
let mut m = GainMapMeta {
multichannel: o.multichannel,
gamma: [o.gamma; 3],
base_offset: [o.offset; 3],
alternate_offset: [o.offset; 3],
..GainMapMeta::default()
};
for c in 0..ch {
// the range always includes 0 (no change) so an SDR-only area encodes exactly
m.min[c] = lo[c].min(0.0);
m.max[c] = hi[c].max(m.min[c] + 1e-4);
}
if !o.multichannel {
m.fill_from_first();
}
m.base_headroom = 0.0;
m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3);
let data = gains
.iter()
.enumerate()
.map(|(i, g)| {
let c = i % ch;
let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0);
(norm.powf(o.gamma) * 255.0).round() as u8
})
.collect();
Ok((GainImage { width: gw, height: gh, channels: ch, data }, m))
}
/// Rebuild the HDR rendition in place: `base` (`width × height` linear RGB) is replaced by the
/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom
/// `H` uses `(H − base_headroom) / (alternate_headroom − base_headroom)`, clamped).
pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> {
let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
if base.len() < n
|| gain.width == 0
|| gain.height == 0
|| !matches!(gain.channels, 1 | 3)
|| gain.data.len() < gain.width * gain.height * gain.channels
{
return Err(Error::Encode("gain map: base and gain map sizes don't match".into()));
}
if !m.is_valid() {
return Err(Error::Encode("gain map: unusable metadata".into()));
}
let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 };
let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32);
let inv_gamma = m.gamma.map(|g| 1.0 / g);
for (i, p) in base.iter_mut().enumerate().take(n) {
let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5);
let (gx, gy) = (x * sx, y * sy);
let mut g = [0f32; 3];
for (c, gc) in g.iter_mut().enumerate() {
let src = if gain.channels == 3 { c } else { 0 };
let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]);
*gc = m.min[c] + (m.max[c] - m.min[c]) * norm;
}
for c in 0..3 {
let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c];
p[c] = v.max(0.0);
}
}
Ok(())
}
/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for
/// Adobe/Google readers) and the Container directory naming the gain map and its length.
fn primary_description(gain_len: usize) -> String {
format!(
concat!(
r#"<rdf:Description rdf:about="" xmlns:Container="http://ns.google.com/photos/1.0/container/" xmlns:Item="http://ns.google.com/photos/1.0/container/item/" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0">"#,
r#"<Container:Directory><rdf:Seq>"#,
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="Primary" Item:Mime="image/jpeg"/></rdf:li>"#,
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="GainMap" Item:Mime="image/jpeg" Item:Length="{}"/></rdf:li>"#,
r#"</rdf:Seq></Container:Directory></rdf:Description>"#
),
gain_len
)
}
/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is
/// none, or when it has no `rdf:RDF` to add to).
fn merge_xmp(xmp: Option<&str>, description: &str) -> String {
if let Some(x) = xmp
&& let Some(i) = x.rfind("</rdf:RDF>")
&& let (Some(a), Some(b)) = (x.get(..i), x.get(i..))
{
return format!("{a}{description}{b}");
}
format!("{XMP_OPEN}{description}{XMP_CLOSE}")
}
/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags).
const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4;
/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists.
fn mpf_placeholder() -> Vec<u8> {
let mut p = b"MPF\0MM".to_vec();
p.extend_from_slice(&42u16.to_be_bytes());
p.extend_from_slice(&8u32.to_be_bytes()); // first IFD
p.extend_from_slice(&3u16.to_be_bytes());
let tag = |p: &mut Vec<u8>, tag: u16, ty: u16, count: u32, value: [u8; 4]| {
p.extend_from_slice(&tag.to_be_bytes());
p.extend_from_slice(&ty.to_be_bytes());
p.extend_from_slice(&count.to_be_bytes());
p.extend_from_slice(&value);
};
tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion
tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages
tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry
p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
// entry 0: baseline primary (0x030000) flagged as the representative image (bit 29)
for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] {
p.extend_from_slice(&attr.to_be_bytes());
p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries
}
p
}
/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and
/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added.
pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
let Samples::U8(data) = base.samples else {
return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into()));
};
let (w, h) = (base.width as usize, base.height as usize);
if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize {
return Err(Error::Encode("gain map JPEG needs an RGB base image".into()));
}
if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize {
return Err(Error::Encode("JPEG dimensions are limited to 65535".into()));
}
if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels {
return Err(Error::Encode("gain map image is malformed".into()));
}
if !m.is_valid() || m.multichannel != (gain.channels == 3) {
return Err(Error::Encode("gain map metadata doesn't match the map".into()));
}
// jpeg_par writes 4:4:4 or 4:2:0
let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub };
let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())];
let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs);
let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len()));
let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?;
let mut iso_marker = ISO_URN.to_vec();
iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map"
segs.push((0xE2, iso_marker));
segs.push((0xE2, mpf_placeholder()));
let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs);
// patch the MPF index: sizes, and the gain map's offset from the MPF endian field
let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?;
let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?;
let entries = endian + MPF_ENTRIES_FROM_ENDIAN;
let (plen, glen) = (primary.len(), gain_jpeg.len());
let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)];
for (at, v) in fields {
let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?;
let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?;
slot.copy_from_slice(&v.to_be_bytes());
}
primary.extend_from_slice(&gain_jpeg);
Ok(primary)
}
/// The gain map of a gain map JPEG.
#[derive(Clone, Debug, PartialEq)]
pub struct JpegGainMap {
pub gain: GainImage,
pub meta: GainMapMeta,
}
/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The
/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`].
pub fn read_jpeg(bytes: &[u8]) -> Option<JpegGainMap> {
let m = jpeg::parse_markers(bytes)?;
let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"));
if !marked {
return None;
}
for e in jpeg::mpf_images(&m) {
let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue };
let Some(gm) = jpeg::parse_markers(data) else { continue };
let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp));
let Some(meta) = meta else { continue };
let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue };
if ch == 3 && !meta.multichannel {
// a single-channel map stored as RGB: use its first channel
let g: Vec<u8> = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect();
return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta });
}
let mut meta = meta;
if ch == 1 && meta.multichannel {
meta.multichannel = false;
meta.fill_from_first();
}
return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta });
}
None
}
/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode).
pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool {
crate::sniff(bytes) == Some(Format::Jpeg)
&& jpeg::parse_markers(bytes).is_some_and(|m| {
(m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")))
&& jpeg::mpf_images(&m)
.iter()
.any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map()))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn meta3() -> GainMapMeta {
GainMapMeta {
multichannel: true,
use_base_colour_space: true,
base_headroom: 0.0,
alternate_headroom: 2.5,
min: [-0.25, -0.5, 0.0],
max: [2.5, 2.25, 2.0],
gamma: [1.0, 1.25, 0.75],
base_offset: [0.015625; 3],
alternate_offset: [0.015625, 0.03125, 0.015625],
}
}
#[test]
fn iso_metadata_round_trips() {
for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] {
let p = m.to_iso();
assert!(p.starts_with(ISO_URN));
let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap();
for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}");
}
}
assert_eq!(back.multichannel, m.multichannel);
assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6);
}
}
#[test]
fn iso_common_denominator_and_rejections() {
// common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0
let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR];
for v in [4u32, 0, 8, 0, 8, 4, 0, 0] {
p.extend_from_slice(&v.to_be_bytes());
}
let m = GainMapMeta::from_iso(&p).unwrap();
assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0));
// versions only (a primary image's marker), unknown version, truncated, backward
assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none());
assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none());
assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none());
let mut back = p.clone();
back[4] |= FLAG_BACKWARD;
assert!(GainMapMeta::from_iso(&back).is_none());
// a zero denominator is rejected, not divided by
let mut z = p.clone();
z[5..9].copy_from_slice(&0u32.to_be_bytes());
assert!(GainMapMeta::from_iso(&z).is_none());
}
#[test]
fn hdrgm_xmp_round_trips_including_per_channel_sequences() {
for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] {
let x = m.to_gain_map_xmp();
assert!(x.contains("apdi:AuxiliaryImageType"));
if m.multichannel {
assert!(x.contains("<hdrgm:GainMapMax><rdf:Seq><rdf:li>2.5</rdf:li>"), "{x}");
}
let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap();
assert_eq!(back.multichannel, m.multichannel);
for c in 0..3 {
assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5);
}
assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5);
}
assert!(GainMapMeta::from_hdrgm_xmp("<x hdrgm:Version=\"1.0\"/>").is_none(), "no GainMapMax");
assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none());
}
fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
// a ramp: SDR rolls off at 1, HDR keeps going to 6
let hdr: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let t = (i % w) as f32 / (w - 1) as f32;
let v = 0.01 + 6.0 * t * t;
[v, v * 0.8, v * 0.6]
})
.collect();
// a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single
// luminance gain can rebuild it exactly
let sdr = hdr
.iter()
.map(|p| {
let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
let k = 1.0 / (1.0 + y * 0.5);
p.map(|v| v * k)
})
.collect();
(sdr, hdr)
}
#[test]
fn compute_then_apply_recovers_the_hdr_rendition() {
let (w, h) = (64, 8);
let (sdr, hdr) = renditions(w, h);
for multichannel in [false, true] {
let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() };
let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap();
assert!(m.is_valid() && m.alternate_headroom > 1.0);
let mut out = sdr.clone();
apply(&mut out, w, h, &g, &m, 1.0).unwrap();
for (a, b) in out.iter().zip(&hdr) {
for c in 0..3 {
// 8-bit codes over the gain range: within ~2 % in linear light
assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}");
}
}
// weight 0 is the base
let mut base = sdr.clone();
apply(&mut base, w, h, &g, &m, 0.0).unwrap();
for (a, b) in base.iter().zip(&sdr) {
assert!((a[0] - b[0]).abs() < 1e-5);
}
}
}
#[test]
fn jpeg_round_trip_and_container_structure() {
let (w, h) = (96, 64);
let (sdr, hdr) = renditions(w, h);
let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
let base: Vec<u8> =
sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect();
let user_xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"/></rdf:RDF></x:xmpmeta>"#;
let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() };
let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap();
assert!(is_gain_map_jpeg(&file));
// the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP
let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (w as u32, h as u32));
let x = d.xmp.unwrap();
assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\""));
// the MPF index points at the gain map, typed as one
let pm = jpeg::parse_markers(&file).unwrap();
let e = jpeg::mpf_images(&pm);
assert_eq!(e.len(), 1);
assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP);
assert_eq!(e[0].offset + e[0].len, file.len());
// and the gain map reads back
let r = read_jpeg(&file).unwrap();
assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1));
assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5);
// the gain map is never a thumbnail
if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) {
assert_ne!(t.source, crate::ThumbnailSource::MpfPreview);
}
}
#[test]
fn garbage_never_panics() {
assert!(read_jpeg(b"").is_none());
assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none());
assert!(!is_gain_map_jpeg(&[0xFF, 0xD8]));
let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40];
b.extend_from_slice(ISO_URN);
b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]);
assert!(read_jpeg(&b).is_none());
let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] };
assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err());
assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err());
}
}
+54 -28
View File
@@ -1,16 +1,18 @@
//! HEIF / HEIC (the iPhone and Mac photo format), read-only.
//!
//! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC
//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC
//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call
//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still
//! detected and opening one is an [`Error::Unsupported`] error, never a panic.
//! still-picture decoder, plus a libheif-matching colour conversion), enabled by this crate's
//! `heif` feature — off by default because HEVC is patent-encumbered and whether a build carries
//! an HEVC decoder is the distributor's call (the same policy as the sibling PhotoCraft). Without
//! the feature, HEIF files are still detected and opening one is an [`Error::Unsupported`] error
//! saying so, never a panic.
//!
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
//! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only
//! mirrors what the container says, and applying it as well would turn the photo twice.
//! The samples come back in the container's own primaries with its ICC profile (if any); like
//! every decoder here, the colour interpretation happens in [`crate::convert::finish`].
//! The samples come back as 16-bit RGB in the container's own primaries, with its ICC profile
//! (iPhones: Display P3) or, without one, its `nclx` / VUI colour description; like every decoder
//! here, the colour interpretation happens in [`crate::convert::finish`].
#[cfg(feature = "heif")]
use crate::convert::{Buf, Meta, Model, Raw, finish};
@@ -22,26 +24,32 @@ const F: Format = Format::Heif;
#[cfg(not(feature = "heif"))]
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
/// only; the wrapper names the exact construct in its own errors).
#[cfg(feature = "heif")]
const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)";
#[cfg(not(feature = "heif"))]
pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result<Decoded> {
Err(Error::Unsupported(F, NOT_IN_BUILD))
}
#[cfg(not(feature = "heif"))]
pub(crate) fn header(_bytes: &[u8]) -> Result<(u32, u32, u16)> {
Err(Error::Unsupported(F, NOT_IN_BUILD))
}
#[cfg(feature = "heif")]
fn err(e: lightcraft_heif::Error) -> Error {
match e {
// This crate's Unsupported takes a fixed reason; the dynamic wording lives in the
// lightcraft-heif error itself.
lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED),
lightcraft_heif::Error::Unsupported(why) => Error::Unsupported(F, why),
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
}
}
/// The displayed size (container transforms applied, so orientation 1), from the container
/// alone: no picture is decoded.
#[cfg(feature = "heif")]
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let info = lightcraft_heif::probe(bytes).map_err(err)?;
Ok((info.width, info.height, 1))
}
#[cfg(feature = "heif")]
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
// The container alone: the declared size is checked before any pixel is decoded.
@@ -50,16 +58,22 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
return Err(Error::TooLarge(info.width as u64, info.height as u64));
}
let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true };
let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?;
// The decoder re-reads the container; trust its output's shape, not the probe's.
let buf = if decoded.sixteen_bit {
let mut v = Vec::with_capacity(decoded.data.len() / 2);
for pair in decoded.data.as_chunks::<2>().0 {
v.push(u16::from_ne_bytes(*pair));
// A small decode (a thumbnail or a cataloguing probe) uses the file's own thumbnail image when
// it is at least as large as asked: iPhones store one of 320 × 240, and decoding it is a
// hundredth of the work of the 12 MP photo.
let thumb = match opts.max_size {
Some((mw, mh)) if mw > 0 && mh > 0 && mw.max(mh) < info.width.max(info.height) => {
lightcraft_heif::decode_thumbnail(bytes, mw.max(mh), &options).ok().flatten().filter(|t| {
// Only a thumbnail of the same picture: same orientation and aspect within 1 %.
let aspect = |w: u32, h: u32| w as f64 / h.max(1) as f64;
(aspect(t.width, t.height) / aspect(info.width, info.height) - 1.0).abs() < 0.01
})
}
Buf::U16(v)
} else {
Buf::U8(decoded.data)
_ => None,
};
let mut decoded = match thumb {
Some(t) => t,
None => lightcraft_heif::decode(bytes, &options).map_err(err)?,
};
let raw = Raw {
width: decoded.width as usize,
@@ -67,11 +81,20 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
model: Model::Rgb,
alpha: decoded.has_alpha,
premultiplied: false,
buf,
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
buf: Buf::U16(std::mem::take(&mut decoded.data)),
bit_depth: decoded.bit_depth,
};
let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() };
finish(F, raw, meta, (decoded.width, decoded.height), opts)
let c = decoded.colour;
let meta = Meta {
icc: decoded.icc,
exif: decoded.exif,
xmp: decoded.xmp,
orientation: Some(1),
// Used when there is no (usable) ICC profile.
hint: crate::space::SourceSpace::from_cicp(c.primaries, c.transfer),
..Default::default()
};
finish(F, raw, meta, (info.width, info.height), opts)
}
#[cfg(test)]
@@ -96,6 +119,8 @@ mod tests {
fn without_the_feature_heif_is_a_clear_unsupported_error() {
let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}");
let h = crate::read_header(HEIC_HEADER);
assert!(matches!(&h, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{h:?}");
}
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
@@ -113,6 +138,7 @@ mod tests {
fn a_heic_rs_panic_is_a_malformed_error() {
let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}");
assert!(crate::read_header(&HEIC_RS_BOX_PANIC).is_err());
}
#[cfg(feature = "heif")]
@@ -120,6 +146,6 @@ mod tests {
fn an_image_sequence_is_unsupported() {
// An ftyp with no meta box: a video track, not a still.
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}");
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("sequence")), "{r:?}");
}
}
+1 -1
View File
@@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec<u8> {
write_matrix_trc(&n.rgb_space(), &n.trc())
}
fn linear_rec2020_profile() -> ColorProfile {
pub(crate) fn linear_rec2020_profile() -> ColorProfile {
let mut p = ColorProfile::new_bt2020();
p.cicp = None;
let lin = ToneReprCurve::Parametric(vec![1.0]);
+61 -6
View File
@@ -25,6 +25,17 @@ pub(crate) struct Markers {
pub adobe_transform: Option<u8>,
/// Offset of the MPF TIFF header within the file and its bytes.
pub mpf: Option<(usize, Vec<u8>)>,
/// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image
/// that has a gain map, the full metadata on the gain map image itself.
pub iso_gainmap: Option<Vec<u8>>,
}
impl Markers {
/// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a
/// picture: never a preview, never the image to show.
pub fn is_gain_map(&self) -> bool {
self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax"))
}
}
pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
@@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
icc_chunks.push((seg[12], &seg[14..]));
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
m.mpf = Some((start + 4, seg[4..].to_vec()));
} else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
&& m.iso_gainmap.is_none()
{
m.iso_gainmap = Some(p.to_vec());
}
}
0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => {
@@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) {
candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail));
}
for (o, l) in mpf_images(&m) {
if let Some(s) = bytes.get(o..o.saturating_add(l)) {
for e in mpf_images(&m) {
if e.kind == MPF_GAIN_MAP {
continue;
}
if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) {
candidates.push((s, ThumbnailSource::MpfPreview));
}
}
@@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
let mut best: Option<(u32, &[u8], ThumbnailSource)> = None;
for (data, src) in candidates {
let Some(pm) = parse_markers(data) else { continue };
// a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview
if pm.is_gain_map() {
continue;
}
let long = pm.width.max(pm.height);
// Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped.
let aspect_ok = m.width > 0
@@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height })
}
/// (file offset, length) of MPF-listed images other than the primary.
fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits).
pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000;
/// An MPF-listed image other than the primary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MpfEntry {
/// File offset and length of the image.
pub offset: usize,
pub len: usize,
/// MP type code (attribute low 24 bits).
pub kind: u32,
}
/// The MPF-listed images other than the primary.
pub(crate) fn mpf_images(m: &Markers) -> Vec<MpfEntry> {
let Some((base, data)) = &m.mpf else { return vec![] };
let Some(t) = exif::Tiff::new(data) else { return vec![] };
let Some(ifd) = t.first_ifd() else { return vec![] };
@@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]];
if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
};
let (size, off) = (rd(4) as usize, rd(8) as usize);
let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
out.push((base + off, size));
out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF });
}
}
out
}
/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale
/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours.
pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec<u8>)> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if !matches!(m.components, 1 | 3) || m.precision != 8 {
return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into()));
}
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? };
let ch = raw.model.channels();
match raw.buf {
Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)),
_ => Err(Error::Malformed(F, "unexpected sample format".into())),
}
}
#[cfg(test)]
mod tests {
use super::*;
+13 -1
View File
@@ -1,5 +1,7 @@
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
@@ -24,11 +26,13 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let gray = header.metadata.grayscale();
let icc = img.original_icc().map(|v| v.to_vec());
let mut meta = Meta { orientation: Some(1), ..Default::default() };
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
if icc.is_none() {
img.request_color_encoding(EnumColourEncoding {
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
white_point: WhitePoint::D65,
primaries: Primaries::Bt2100,
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
tf: TransferFunction::Linear,
rendering_intent: RenderingIntent::Relative,
});
@@ -55,11 +59,19 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
let keep = n_color + has_alpha as usize;
let src = fb.buf();
let mut v = Vec::with_capacity(fw * fh * keep);
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
for px in src.chunks_exact(ch) {
let start = v.len();
v.extend_from_slice(&px[..keep.min(ch)]);
if keep > ch {
v.push(1.0);
}
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
for (o, m) in rgb.iter_mut().zip(&to_2020) {
*o = m[0] * r + m[1] * g + m[2] * b;
}
}
}
let raw = Raw {
width: fw,
+188
View File
@@ -0,0 +1,188 @@
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
/// exports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JxlEffort {
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
Fast,
#[default]
Normal,
}
impl JxlEffort {
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"fast" => Self::Fast,
"normal" => Self::Normal,
_ => return None,
})
}
}
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum JxlColour {
#[default]
Srgb,
/// P3 primaries, D65, sRGB curve.
DisplayP3,
/// Rec. 2020 primaries with the BT.709 curve.
Rec2020,
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
/// samples are already PQ-encoded.
Rec2020Pq,
}
/// JPEG XL encoder settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct JxlOptions {
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
pub quality: u8,
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
pub lossless: bool,
pub effort: JxlEffort,
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
/// preview that refines to the exact image).
pub progressive: bool,
pub colour: JxlColour,
}
impl Default for JxlOptions {
fn default() -> Self {
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
}
}
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
{
let pixels = Pixels::from(img, o.colour)?;
// jixel asserts internally; a panic there is an export error, not a crash
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
}
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
{
let _ = (o, meta);
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
}
}
/// Interleaved RGB or RGBA samples for jixel.
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
enum Pixels {
U8 { data: Vec<u8>, alpha: bool },
U16 { data: Vec<u16>, alpha: bool },
}
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
impl Pixels {
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
let ch = img.channels as usize;
// keep alpha only when it says something
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
let px = s.chunks_exact(ch).take(n);
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
let out_ch = if alpha { 4 } else { 3 };
let mut v = Vec::with_capacity(n * out_ch);
for c in px {
match c {
[g] => v.extend_from_slice(&[*g, *g, *g]),
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
[] => {}
}
}
(v, alpha)
}
Ok(match img.samples {
Samples::U8(s) => {
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
Pixels::U8 { data, alpha }
}
Samples::U16(s) => {
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
Pixels::U16 { data, alpha }
}
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
let s: Vec<u16> = s
.chunks_exact(ch)
.take(n)
.flat_map(|c| match c {
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
[] => [0; 4],
})
.collect();
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
Pixels::U16 { data, alpha }
}
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
})
}
}
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
let colour = match o.colour {
JxlColour::Srgb => ColorEncoding::srgb(),
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
};
let mut c = EncodeConfig::default()
.with_quality(f32::from(o.quality.clamp(1, 100)))
.with_color_encoding(colour)
.with_lossless(o.lossless)
.with_progressive(o.progressive)
.with_speed(match o.effort {
JxlEffort::Fast => Speed::Fastest,
JxlEffort::Normal => Speed::Fast,
});
if o.colour == JxlColour::Rec2020Pq {
// PQ code values are absolute: 1.0 = 10 000 cd/m²
c = c.with_intensity_target(10_000.0);
}
if o.lossless
&& let Some(icc) = meta.icc
{
c = c.with_icc_profile(icc.to_vec());
}
if let Some(exif) = meta.exif {
c = c.with_exif(exif.to_vec());
}
if let Some(xmp) = meta.xmp {
c = c.with_xmp(xmp.as_bytes().to_vec());
}
let r = match px {
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
};
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
}
+33 -12
View File
@@ -9,7 +9,8 @@
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//!
//! No decoder panics on malformed input (property-tested).
@@ -17,16 +18,20 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod display;
pub mod encode;
pub mod exif;
pub mod gainmap;
mod heif;
pub mod icc;
mod jpeg;
pub mod jpeg_par;
#[cfg(feature = "jxl")]
mod jxl;
mod jxl_encode;
mod other;
mod png_codec;
pub mod proof;
mod psd;
mod sniff;
pub mod space;
@@ -34,8 +39,10 @@ mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, 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};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
@@ -171,9 +178,9 @@ pub struct Header {
}
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
/// decode.
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read
/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to
/// a small decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
@@ -196,6 +203,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
Format::Tiff => tiff_codec::header(bytes)?,
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
Format::Heif => heif::header(bytes)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
@@ -217,6 +225,8 @@ pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: Na
#[doc(hidden)]
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
let opts = *opts;
// The reason a format can't be decoded lives in one place, `Format::not_decodable`.
let unsupported = || Err(Error::Unsupported(format, format.not_decodable().unwrap_or("not decodable in this build")));
match format {
Format::Jpeg => jpeg::decode(bytes, &opts),
Format::Png => png_codec::decode(bytes, &opts),
@@ -227,20 +237,31 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
#[cfg(feature = "jxl")]
Format::Jxl => jxl::decode(bytes, &opts),
#[cfg(not(feature = "jxl"))]
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
Format::Jxl => unsupported(),
Format::Heif => heif::decode(bytes, &opts),
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),
}
}
/// 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.
+4 -19
View File
@@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
}
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
if let Some(c) = &info.coding_independent_code_points {
let named = match c.color_primaries {
1 => Some(NamedSpace::Srgb),
9 => Some(NamedSpace::Rec2020),
12 => Some(NamedSpace::DisplayP3),
_ => None,
};
let trc = match c.transfer_function {
13 => Some(Trc::Srgb),
1 | 6 | 14 | 15 => Some(Trc::Rec709),
8 => Some(Trc::Linear),
4 => Some(Trc::Gamma(2.2)),
_ => None,
};
if let (Some(n), Some(t)) = (named, trc) {
let mut s = SourceSpace::named(n, SpaceOrigin::Container);
s.trc = Some([t.clone(), t.clone(), t]);
return Some(s);
}
if let Some(s) =
info.coding_independent_code_points.as_ref().and_then(|c| SourceSpace::from_cicp(c.color_primaries.into(), c.transfer_function.into()))
{
return Some(s);
}
if info.srgb.is_some() {
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));
+174
View File
@@ -0,0 +1,174 @@
//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix
//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020)
//! through the device and back, so the loupe shows what the print can hold.
//!
//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative
//! colorimetric: colours outside the device gamut come back mapped into it.
//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white
//! (the profile's media white point) replaces the screen's white, and the ink's black is the
//! darkest the device tables give (no black-point compensation). The white is applied as a
//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the
//! near-neutral whites of papers).
//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour.
//!
//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling);
//! the transforms are moxcms's. Own design otherwise.
use lightcraft_color::{D50, D65, REC2020, bradford};
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions};
use crate::display::MAX_PROFILE_BYTES;
/// Rendering intent of the forward (image → device) conversion.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ProofIntent {
#[default]
Perceptual,
Relative,
}
impl ProofIntent {
pub fn parse(s: &str) -> Option<ProofIntent> {
match s.to_ascii_lowercase().as_str() {
"perceptual" => Some(ProofIntent::Perceptual),
"relative" | "relativecolorimetric" => Some(ProofIntent::Relative),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
ProofIntent::Perceptual => "perceptual",
ProofIntent::Relative => "relative",
}
}
}
/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index
/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance
/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside).
#[derive(Clone, Debug, PartialEq)]
pub struct ProofTable {
pub n: usize,
pub data: Vec<[f32; 4]>,
pub description: Option<String>,
}
/// Grid size of [`ProofTable`]s.
pub const PROOF_GRID: usize = 33;
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
}
/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here
/// panics on a hostile profile.
pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result<ProofTable, String> {
if bytes.len() > MAX_PROFILE_BYTES {
return Err("the profile is too large".into());
}
let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
let (layout, ch) = match dev.color_space {
DataColorSpace::Rgb => (Layout::Rgb, 3),
DataColorSpace::Cmyk => (Layout::Rgba, 4),
DataColorSpace::Gray => (Layout::Gray, 1),
_ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()),
};
let lin = crate::icc::linear_rec2020_profile();
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
let fwd_i = match intent {
ProofIntent::Perceptual => RenderingIntent::Perceptual,
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
};
let rel = RenderingIntent::RelativeColorimetric;
let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?;
let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?;
let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?;
let n = PROOF_GRID;
let grid: Vec<f32> = (0..n * n * n)
.flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)))
.collect();
let round_trip = |there: &dyn moxcms::TransformExecutor<f32>| -> Option<Vec<f32>> {
let mut device = vec![0f32; n * n * n * ch];
there.transform(&grid, &mut device).ok()?;
let mut out = vec![0f32; n * n * n * 3];
back.transform(&device, &mut out).ok()?;
Some(out)
};
let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?;
let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?;
let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] };
let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0));
let data = (0..n * n * n)
.map(|i| {
let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0);
let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max);
[c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist]
})
.collect();
let description = crate::icc::parse(bytes).and_then(|i| i.description);
Ok(ProofTable { n, data, description })
}
/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point.
fn paper_white(p: &ColorProfile) -> [f32; 3] {
let Some(w) = p.media_white_point else { return [1.0; 3] };
let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz());
let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] };
// the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it
let d50 = to_xyz.apply([1.0; 3]);
let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]];
let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]);
if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::space::NamedSpace;
fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] {
t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]]
}
#[test]
fn srgb_proof_flags_wide_colours_and_keeps_grey() {
let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap();
let top = t.n - 1;
// Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged
assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0]));
let g = at(&t, [16, 16, 16]);
assert!(g[3] < 0.01, "{g:?}");
let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0);
assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}");
let w = at(&t, [top; 3]);
assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}");
}
#[test]
fn paper_white_dims_and_tints_white() {
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
// a warm, slightly dull paper
p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 });
let bytes = p.encode().unwrap();
let top = PROOF_GRID - 1;
let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap();
let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap();
let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3]));
assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}");
assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}");
}
#[test]
fn hostile_profiles_are_errors() {
assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err());
assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err());
let good = crate::icc::write_named(NamedSpace::DisplayP3);
for i in (0..good.len()).step_by(11) {
let mut b = good.clone();
b[i] ^= 0x5a;
let _ = proof_table(&b, ProofIntent::Perceptual, true);
}
}
}
+177
View File
@@ -1,8 +1,11 @@
//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description):
//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data;
//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative.
//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document
//! with no layers, so an image editor opens it as one background layer and saves back to it.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::encode::{EncodeImage, EncodeMeta, Samples};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
const F: Format = Format::Psd;
@@ -211,6 +214,129 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
/// The largest side a PSD (version 1) may have; larger documents need PSB.
pub const MAX_SIDE: u32 = 30_000;
/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF
/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits
/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB;
/// alpha is dropped (the render is opaque). Float samples are refused.
pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
let (w, h) = (img.width, img.height);
if w > MAX_SIDE || h > MAX_SIDE {
return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF")));
}
let ch = usize::from(img.channels);
let color = if ch <= 2 { 1 } else { 3 };
let (depth, bpc) = match img.samples {
Samples::U8(_) => (8u16, 1usize),
Samples::U16(_) => (16, 2),
Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())),
};
let (wu, hu) = (w as usize, h as usize);
let mut b = Vec::new();
b.extend_from_slice(b"8BPS");
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&[0; 6]);
b.extend_from_slice(&(color as u16).to_be_bytes());
b.extend_from_slice(&h.to_be_bytes());
b.extend_from_slice(&w.to_be_bytes());
b.extend_from_slice(&depth.to_be_bytes());
b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
let res = resource_section(meta);
b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes());
b.extend_from_slice(&res);
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none
// planar samples, one plane per colour channel (big-endian)
let plane = |c: usize| -> Vec<u8> {
let mut p = Vec::with_capacity(wu * hu * bpc);
match img.samples {
Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)),
Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())),
Samples::F32(_) => {}
}
p
};
if bpc == 1 {
b.extend_from_slice(&1u16.to_be_bytes());
let mut counts = Vec::with_capacity(color * hu * 2);
let mut data = Vec::new();
for c in 0..color {
for row in plane(c).chunks_exact(wu) {
let start = data.len();
packbits(row, &mut data);
let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?;
counts.extend_from_slice(&n.to_be_bytes());
}
}
b.extend_from_slice(&counts);
b.extend_from_slice(&data);
} else {
b.extend_from_slice(&0u16.to_be_bytes());
for c in 0..color {
b.extend_from_slice(&plane(c));
}
}
Ok(b)
}
/// The image resources section for `meta`.
fn resource_section(meta: &EncodeMeta) -> Vec<u8> {
let mut out = Vec::new();
let mut add = |id: u16, data: &[u8]| {
out.extend_from_slice(b"8BIM");
out.extend_from_slice(&id.to_be_bytes());
out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
if data.len() % 2 == 1 {
out.push(0);
}
};
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
// ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice
let fixed = (u32::from(ppi) << 16).to_be_bytes();
let one = 1u16.to_be_bytes();
add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat());
}
if let Some(icc) = meta.icc {
add(1039, icc);
}
if let Some(exif) = meta.exif {
add(1058, exif);
}
if let Some(xmp) = meta.xmp {
add(1060, xmp.as_bytes());
}
out
}
/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the
/// rest as literals of at most 128 bytes).
fn packbits(src: &[u8], out: &mut Vec<u8>) {
let mut rest = src;
while let Some(&b) = rest.first() {
// a run of 2 to 128 equal bytes
let run = rest.iter().take(128).take_while(|&&x| x == b).count();
if run >= 2 {
// 1 - run in 2..=128: -1..=-127, as a signed byte
out.push((1 - run as i16) as i8 as u8);
out.push(b);
rest = rest.get(run..).unwrap_or_default();
continue;
}
// a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row
let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count();
let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired };
let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..]));
out.push(lit.len().saturating_sub(1) as u8);
out.extend_from_slice(lit);
rest = tail;
}
}
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
/// depth, colour mode or compression, or merged image data that runs past the end of the file
@@ -434,4 +560,55 @@ mod tests {
let q = d.image.get(0, 0);
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
}
#[test]
fn packbits_round_trips() {
let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::<Vec<_>>()];
for row in rows {
let mut enc = Vec::new();
super::packbits(row, &mut enc);
let mut out = vec![0u8; row.len()];
unpackbits(&enc, &mut out).unwrap();
assert_eq!(out, row);
}
}
#[test]
fn flat_psd_round_trips_8_and_16_bit() {
let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb);
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x:xmpmeta/>"), ppi: Some(300), ..Default::default() };
// 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient
let px: Vec<u8> = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect();
let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap();
assert_eq!(super::header(&b).unwrap(), (5, 3, 1));
let d = decode(&b, &DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (5, 3));
assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept");
assert_eq!(d.xmp.as_deref(), Some("<x:xmpmeta/>"));
// 16-bit RGB, raw planes: the samples come back exactly
let px16: Vec<u16> = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect();
let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap();
assert_eq!(&b[22..24], &16u16.to_be_bytes());
let mut r = Rd { b: &b, p: 26 };
let cm = r.u32().unwrap() as usize;
r.take(cm).unwrap();
let res = r.u32().unwrap() as usize;
r.take(res).unwrap();
assert_eq!(r.u32().unwrap(), 0, "no layers");
assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw");
let red: Vec<u16> = (0..8).map(|_| r.u16().unwrap()).collect();
assert_eq!(red, px16.iter().step_by(3).copied().collect::<Vec<_>>());
assert!(decode(&b, &DecodeOptions::default()).is_ok());
// gray 16-bit
let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap();
assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode");
assert!(decode(&g, &DecodeOptions::default()).is_ok());
}
#[test]
fn flat_psd_refuses_what_it_cannot_hold() {
let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err();
assert!(e.to_string().contains("float"), "{e}");
let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err();
assert!(e.to_string().contains("TIFF"), "{e}");
}
}
+13 -4
View File
@@ -64,11 +64,20 @@ impl Format {
/// Whether [`crate::decode`] can decode this format in this build.
pub fn can_decode(self) -> bool {
self.not_decodable().is_none()
}
/// Why [`crate::decode`] can't decode this format in this build (`None`: it can). The
/// [`crate::Error::Unsupported`] a decode returns carries the same reason.
pub fn not_decodable(self) -> Option<&'static str> {
match self {
Format::Jxl => cfg!(feature = "jxl"),
Format::Heif => cfg!(feature = "heif"),
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
_ => true,
#[cfg(not(feature = "jxl"))]
Format::Jxl => Some("built without the `jxl` feature"),
#[cfg(not(feature = "heif"))]
Format::Heif => Some(crate::heif::NOT_IN_BUILD),
Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"),
Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"),
_ => None,
}
}
+21
View File
@@ -255,6 +255,27 @@ impl SourceSpace {
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
}
/// The space named by ITU-T H.273 code points (PNG `cICP`, HEIF `nclx`, HEVC VUI), when
/// both the primaries and the transfer are ones this crate models.
pub(crate) fn from_cicp(primaries: u16, transfer: u16) -> Option<SourceSpace> {
let named = match primaries {
1 => NamedSpace::Srgb,
9 => NamedSpace::Rec2020,
12 => NamedSpace::DisplayP3,
_ => return None,
};
let trc = match transfer {
13 => Trc::Srgb,
1 | 6 | 14 | 15 => Trc::Rec709,
8 => Trc::Linear,
4 => Trc::Gamma(2.2),
_ => return None,
};
let mut s = SourceSpace::named(named, SpaceOrigin::Container);
s.trc = Some([trc.clone(), trc.clone(), trc]);
Some(s)
}
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
if let Some(n) = self.named {
+9
View File
@@ -66,6 +66,15 @@ fn bench_24mp_jpeg() {
let rgba = d.to_srgb8();
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
#[cfg(feature = "jxl")]
for (label, o) in [
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
("encode JPEG XL q90 effort normal", JxlOptions::default()),
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
] {
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
println!(" size {:.1} MB", b.len() as f64 / 1e6);
}
}
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
+13 -1
View File
@@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) {
let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
let _ = lightcraft_codecs::gainmap::read_jpeg(bytes);
let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes);
}
fn gain_map_jpeg(meta: &EncodeMeta) -> Vec<u8> {
use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg};
let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect();
let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect();
let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
let base: Vec<u8> = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect();
encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap()
}
#[test]
@@ -91,6 +102,7 @@ fn seeds() -> &'static Vec<Vec<u8>> {
encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(),
encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(),
encode_webp_lossless(&e, &meta).unwrap(),
gain_map_jpeg(&meta),
]
})
}
@@ -106,7 +118,7 @@ proptest! {
}
#[test]
fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
let mut b = seeds()[which].clone();
for (i, v) in flips {
let i = i.index(b.len());
+98
View File
@@ -0,0 +1,98 @@
//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from
//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a
//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input.
#![cfg(feature = "heif")]
use lightcraft_codecs::*;
use lightcraft_heif::testdata::{Prop, Spec, build};
fn grey(x: u32, y: u32) -> [u16; 3] {
[((x * 7 + y * 13) % 220 + 20) as u16, 128, 128]
}
/// EXIF whose Orientation (6) only mirrors the container's `irot`: it must not turn the photo again.
fn exif_orientation_6() -> Vec<u8> {
let mut t = b"MM\0\x2a\0\0\0\x08\0\x01".to_vec();
t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0]);
t.extend_from_slice(&[0, 0, 0, 0]);
t
}
/// The thumbnail is flat mid grey so a test can tell it from the photo.
fn iphone_like() -> Vec<u8> {
build(&Spec {
props: vec![Prop::Icc(icc::write_named(NamedSpace::DisplayP3)), Prop::Irot(3)],
exif: Some(exif_orientation_6()),
thumbnail: Some(&|_, _| [126, 128, 128]),
ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap"],
..Spec::new(96, 64, &grey)
})
}
#[test]
fn an_iphone_like_heic_decodes_upright_in_display_p3() {
let bytes = iphone_like();
assert_eq!(sniff(&bytes), Some(Format::Heif));
let d = decode(&bytes, DecodeOptions::default()).unwrap();
// irot 3 = 90° clockwise, applied by the decoder; the EXIF Orientation is not applied again.
assert_eq!((d.width, d.height, d.source_width, d.source_height, d.orientation), (64, 96, 64, 96, 1));
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
assert_eq!((d.bit_depth, d.has_alpha, d.float), (8, false, false));
assert!(d.exif.is_some() && d.icc.is_some());
// No nclx and no VUI: limited range, BT.601; neutral chroma stays neutral.
let srgb = d.to_srgb8();
let px = srgb.data[0];
assert!(px[0].abs_diff(px[1]) <= 1 && px[1].abs_diff(px[2]) <= 1, "{px:?}");
let h = read_header(&bytes).unwrap();
assert_eq!((h.format, h.width, h.height, h.orientation), (Format::Heif, 64, 96, 1));
}
#[test]
fn a_small_decode_uses_the_embedded_thumbnail() {
let bytes = iphone_like();
// The 48 × 32 thumbnail, turned like the photo, covers a 32-pixel request.
let d = decode(&bytes, DecodeOptions::fit(32, 32)).unwrap();
assert!(d.width.max(d.height) <= 32 && d.height > d.width, "{}x{}", d.width, d.height);
assert_eq!((d.source_width, d.source_height), (64, 96));
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
let flat = d.to_srgb8();
assert!(flat.data.iter().all(|p| *p == flat.data[0]), "the flat thumbnail, not the photo");
// A thumbnail too small for the request: the photo is decoded and resized.
let d = decode(&bytes, DecodeOptions::fit(60, 60)).unwrap();
assert_eq!((d.width.max(d.height), d.source_width), (60, 64));
let photo = d.to_srgb8();
assert!(photo.data.iter().any(|p| *p != photo.data[0]));
let t = decode_thumbnail(&bytes, 32).unwrap();
assert_eq!((t.source_width, t.source_height), (64, 96));
}
#[test]
fn nclx_colour_and_ten_bit() {
// Display P3 primaries, sRGB transfer, BT.709 matrix, full range: the nclx alone names the space.
let bytes = build(&Spec { bit_depth: 10, props: vec![Prop::Nclx(12, 13, 1, true)], ..Spec::new(64, 32, &|x, _| [(x * 16) as u16, 512, 512]) });
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
assert_eq!(d.space.origin, SpaceOrigin::Container);
assert_eq!(d.bit_depth, 10);
// Full-range 10-bit neutral steps of 16 code values: linear values rise monotonically.
let row: Vec<f32> = (0..64).map(|x| d.image.row(0)[x][1]).collect();
assert!(row.windows(2).all(|w| w[1] > w[0]), "{row:?}");
}
#[test]
fn hostile_heic_is_an_error_never_a_panic() {
let bytes = iphone_like();
let full = decode(&bytes, DecodeOptions::default()).unwrap();
for cut in (12..bytes.len()).step_by(11) {
let b = &bytes[..cut];
// Cut inside the photo: an error. Cut in a trailing item (gain map, thumbnail): the photo.
if let (Ok(_), Ok(d)) = (read_header(b), decode(b, DecodeOptions::default())) {
assert_eq!(d.image.data, full.image.data, "cut at {cut}");
}
let _ = decode_thumbnail(b, 16);
}
let r = decode(&bytes, DecodeOptions { max_pixels: 64 * 96 - 1, ..Default::default() });
assert!(matches!(r, Err(Error::TooLarge(64, 96))), "{r:?}");
}
+198
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);
@@ -635,3 +653,183 @@ fn print_resolution_is_written() {
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
}
#[test]
fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() {
// linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x
let (w, h) = (64u32, 4u32);
let rgb: Vec<f32> = (0..w * h)
.flat_map(|i| {
let v = 0.01 * 1.11f32.powi((i % w) as i32);
[v, v * 0.5, v * 0.25]
})
.collect();
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"/>"#;
let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap();
let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap();
let cicp = r.info().coding_independent_code_points.expect("cICP");
assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ");
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let f = r.next_frame(&mut buf).unwrap();
assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h));
// every sample decodes back to its luminance
for x in [0usize, 20, 63] {
for c in 0..3 {
let o = (x * 3 + c) * 2;
let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0;
let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS;
assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e));
}
}
// cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m²
let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI");
let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS;
assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}");
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept");
assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept");
assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP");
}
#[test]
fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err());
assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err());
assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err());
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
}
/// Largest per-channel difference between two linear images.
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossless_exact_with_metadata() {
let img = gradient(97, 61);
let icc = write_named(NamedSpace::Srgb);
let exif = minimal_exif(1);
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
let o = JxlOptions { lossless: true, ..Default::default() };
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
assert_eq!(sniff(&b), Some(Format::Jxl));
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (97, 61));
assert!(!d.has_alpha, "opaque alpha is dropped");
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
assert_eq!(d.xmp.as_deref(), Some(xmp));
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
// 16-bit lossless keeps every code value
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.bit_depth, 16);
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
let want = decode(&png, DecodeOptions::default()).unwrap();
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
let img = smooth(160, 120);
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
for (q, b) in [(95, &hi), (40, &lo)] {
let d = decode(b, DecodeOptions::default()).unwrap();
let p = psnr8(&opaque, &d.to_srgb8());
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
}
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
for progressive in [false, true] {
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
}
}
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
// profile, decode to the same colours in the working space
let img = smooth(96, 64);
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
let icc = write_named(named);
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
let e = EncodeImage::rgba8(&opaque);
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
let a = decode(&lossy, DecodeOptions::default()).unwrap();
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
let b = decode(&exact, DecodeOptions::default()).unwrap();
let d = max_diff(&a.to_working(), &b.to_working());
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
}
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_alpha_and_gray() {
let mut img = gradient(32, 24);
img.data[5][3] = 0;
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert!(d.has_alpha);
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
.unwrap();
let d = decode(&b, DecodeOptions::default()).unwrap();
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_hdr_pq() {
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
let (w, h) = (64usize, 32usize);
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
for lossless in [false, true] {
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
// the file's own samples: PQ code values for 203 and 812 cd/m²
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
let m = &img.image_header().metadata;
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
let r = img.render_frame(0).unwrap();
let fb = r.image_all_channels();
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
}
}
// float samples are HDR only
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
assert!(matches!(sdr, Err(Error::Encode(_))));
}
#[test]
#[cfg(feature = "jxl")]
fn jxl_bad_input_is_an_error() {
let px = [0u8; 12];
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
// a 1×1 image and quality 1 still encode
let one = [10u8, 20, 30];
let b =
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
}
+2 -1
View File
@@ -149,7 +149,8 @@ impl RgbSpace {
}
}
const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in.
pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
+2
View File
@@ -12,6 +12,7 @@
//! blend with the plain demosaic that the Amount slider controls.
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
//! - [`remote`]: running tiles on another computer's graphics card over TCP (a [`run::TileRunner`] and its server).
//!
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
//! be finite, and a malformed file is an error, never a panic.
@@ -26,6 +27,7 @@ pub mod onnx;
mod onnx_proto;
pub mod reference;
pub mod remote;
#[cfg(feature = "runtime")]
pub mod runtime;
+567
View File
@@ -0,0 +1,567 @@
//! Remote denoise: run a model's tiles on another computer's graphics card over TCP. Meant for a LAN or a VPN such as
//! Tailscale or WireGuard: the connection itself is not encrypted.
//!
//! [`Remote`] is a [`TileRunner`] that sends each tile to a server; [`serve`] runs one (`lightcraft-cli
//! denoise-serve`). Both ends must run the same model, checked by its identity ([`ModelId`]) when they greet.
//!
//! Protocol (version 1). Each message is one JSON line (`\n`-terminated, ≤ 4 KiB), optionally followed by a payload of
//! `bytes` bytes: the tile's samples as little-endian half floats, byte-shuffled (every sample's low byte, then every
//! high byte) and deflated, which roughly halves what crosses a slow link.
//!
//! ```text
//! → {"v":1,"token":"…","op":"hello"}
//! ← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"}}
//! → {"v":1,"token":"…","op":"run","model":"<sha256>","tile":T,"bytes":N} + N bytes (4 planes of T × T)
//! ← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T)
//! ← {"ok":false,"error":"…"} (any failure)
//! ```
//!
//! A connection serves any number of requests. The server refuses a wrong token, another model than its own, tiles of
//! another size, oversized headers and payloads (also ones that inflate past their tile), and caps concurrent
//! connections; the client gives up on a server that keeps failing, so the computer's own runner does the rest.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use serde::Serialize;
use serde_json::{Value, json};
use crate::product::{from_f16, to_f16};
use crate::run::{Error, TileRunner};
pub const PROTOCOL: u64 = 1;
pub const DEFAULT_PORT: u16 = 7990;
const MAX_LINE: usize = 4096;
/// Largest tile side in cells (a 1024-cell tile is a 2048 × 2048 pixel answer).
pub const MAX_TILE: usize = 1024;
/// Shortest token a server accepts.
pub const MIN_TOKEN: usize = 16;
/// Which model a server runs: both ends must agree before tiles cross.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ModelId {
pub id: String,
pub version: String,
/// SHA-256 of the model file (hex).
pub sha256: String,
}
fn bad(m: impl Into<String>) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, m.into())
}
fn read_line(r: &mut impl BufRead) -> std::io::Result<Option<String>> {
let mut buf = Vec::new();
let n = r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf)?;
if n == 0 {
return Ok(None);
}
if buf.last() != Some(&b'\n') {
return Err(bad("message header too long or truncated"));
}
String::from_utf8(buf).map(Some).map_err(|_| bad("header is not UTF-8"))
}
/// Samples → half floats → byte-shuffled → deflated.
pub fn pack(data: &[f32]) -> Vec<u8> {
let n = data.len();
let mut b = vec![0u8; n * 2];
let (lo, hi) = b.split_at_mut(n);
for ((v, l), h) in data.iter().zip(lo.iter_mut()).zip(hi.iter_mut()) {
[*l, *h] = to_f16(*v).to_le_bytes();
}
miniz_oxide::deflate::compress_to_vec(&b, 1)
}
/// The inverse of [`pack`] for exactly `n` samples; refuses anything that inflates to more.
pub fn unpack(bytes: &[u8], n: usize) -> std::io::Result<Vec<f32>> {
let b = miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, n * 2).map_err(|_| bad("payload doesn't inflate to the tile size"))?;
if b.len() != n * 2 {
return Err(bad("payload size doesn't match the tile"));
}
let (lo, hi) = b.split_at(n);
Ok(lo.iter().zip(hi).map(|(l, h)| from_f16(u16::from_le_bytes([*l, *h]))).collect())
}
fn write_payload(w: &mut impl Write, mut header: Value, data: &[f32]) -> std::io::Result<()> {
let packed = pack(data);
header["bytes"] = json!(packed.len());
writeln!(w, "{header}")?;
w.write_all(&packed)
}
/// Read a `bytes`-byte payload holding `n` samples (`bytes` is capped by the tile, so a hostile header can't make us
/// allocate more than an uncompressed tile plus slack).
fn read_payload(r: &mut impl Read, bytes: usize, n: usize) -> std::io::Result<Vec<f32>> {
if bytes > n * 2 + 4096 {
return Err(bad("payload larger than its tile"));
}
let mut b = vec![0u8; bytes];
r.read_exact(&mut b)?;
unpack(&b, n)
}
fn field(v: &Value, k: &str) -> Option<usize> {
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
}
/// Constant-time comparison (the token must not leak through timing).
fn same(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut d = a.len() ^ b.len();
for i in 0..a.len().max(b.len()) {
d |= (a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)) as usize;
}
d == 0
}
/// What a server runs a tile with: four planes of `tile × tile` cells in, three planes of `2·tile × 2·tile` out.
pub type RunTile = Arc<dyn Fn(&[f32]) -> Result<Vec<f32>, Error> + Send + Sync>;
/// A server's settings.
#[derive(Clone, Debug)]
pub struct ServerOptions {
/// Shared secret every request must carry (at least [`MIN_TOKEN`] characters).
pub token: String,
pub max_connections: usize,
/// Idle and transfer limit per connection.
pub timeout: Duration,
}
impl Default for ServerOptions {
fn default() -> Self {
ServerOptions { token: String::new(), max_connections: 8, timeout: Duration::from_secs(120) }
}
}
/// Serve `model` (tiles of `tile` cells, run by `run` on `device`) until the listener fails. `log` gets one line per event.
pub fn serve(
listener: TcpListener,
model: ModelId,
tile: usize,
device: String,
run: RunTile,
opts: ServerOptions,
log: Arc<dyn Fn(&str) + Send + Sync>,
) -> Result<(), String> {
if opts.token.trim().chars().count() < MIN_TOKEN {
return Err(format!("the token must be at least {MIN_TOKEN} characters"));
}
if tile == 0 || tile > MAX_TILE {
return Err(format!("tile size {tile} is outside 1..={MAX_TILE}"));
}
let active = Arc::new(AtomicUsize::new(0));
let shared = Arc::new((model, device));
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into());
if active.load(Ordering::SeqCst) >= opts.max_connections.max(1) {
let mut s = stream;
let _ = writeln!(s, "{}", json!({"ok": false, "error": "server busy"}));
log(&format!("{peer}: refused (busy)"));
continue;
}
active.fetch_add(1, Ordering::SeqCst);
let (sh, r, o, l, n) = (shared.clone(), run.clone(), opts.clone(), log.clone(), active.clone());
let spawned = std::thread::Builder::new().name(format!("denoise {peer}")).spawn(move || {
if let Err(e) = handle(stream, &sh.0, tile, &sh.1, &*r, &o, &*l) {
l(&format!("{peer}: {e}"));
}
n.fetch_sub(1, Ordering::SeqCst);
});
if spawned.is_err() {
active.fetch_sub(1, Ordering::SeqCst);
log("could not start a connection thread");
}
}
Ok(())
}
fn handle(
stream: TcpStream,
model: &ModelId,
tile: usize,
device: &str,
run: &dyn Fn(&[f32]) -> Result<Vec<f32>, Error>,
opts: &ServerOptions,
log: &dyn Fn(&str),
) -> std::io::Result<()> {
stream.set_read_timeout(Some(opts.timeout))?;
stream.set_write_timeout(Some(opts.timeout))?;
stream.set_nodelay(true)?;
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
let mut r = BufReader::new(stream.try_clone()?);
let mut w = stream;
let mut tiles = 0usize;
let fail = |w: &mut TcpStream, msg: &str| writeln!(w, "{}", json!({"ok": false, "error": msg}));
while let Some(line) = read_line(&mut r)? {
let Ok(req) = serde_json::from_str::<Value>(&line) else {
fail(&mut w, "malformed request")?;
break;
};
if req.get("v").and_then(Value::as_u64) != Some(PROTOCOL) {
fail(&mut w, &format!("protocol version {PROTOCOL} only"))?;
break;
}
if !same(req.get("token").and_then(Value::as_str).unwrap_or(""), &opts.token) {
fail(&mut w, "bad token")?;
log(&format!("{peer}: bad token"));
break;
}
match req.get("op").and_then(Value::as_str) {
Some("hello") => writeln!(w, "{}", json!({"ok": true, "v": PROTOCOL, "device": device, "model": model, "tile": tile}))?,
Some("run") => {
let (Some(t), Some(bytes)) = (field(&req, "tile"), field(&req, "bytes")) else {
fail(&mut w, "missing tile size or payload size")?;
break;
};
if t != tile {
fail(&mut w, &format!("this server runs {tile}-cell tiles, not {t}"))?;
break;
}
// the payload is read before any refusal below, so the stream stays in step
let input = match read_payload(&mut r, bytes, 4 * t * t) {
Ok(d) => d,
Err(e) => {
fail(&mut w, &e.to_string())?;
break;
}
};
if req.get("model").and_then(Value::as_str) != Some(model.sha256.as_str()) {
fail(&mut w, &format!("model mismatch: this server runs {} {}", model.id, model.version))?;
continue;
}
match run(&input) {
Ok(out) if out.len() == 3 * 4 * t * t && out.iter().all(|v| v.is_finite()) => {
write_payload(&mut w, json!({"ok": true, "tile": t}), &out)?;
tiles += 1;
}
Ok(_) => fail(&mut w, "the model's answer has the wrong size or numbers that are not finite")?,
Err(e) => fail(&mut w, &e.to_string())?,
}
}
_ => {
fail(&mut w, "unknown op")?;
break;
}
}
w.flush()?;
}
if tiles > 0 {
log(&format!("{peer}: {tiles} tiles"));
}
Ok(())
}
/// What a server said in its greeting.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ServerInfo {
pub address: String,
pub device: String,
pub model: ModelId,
pub tile: usize,
}
/// Tiles the server may get wrong (an error, a broken connection) before it stops being used.
pub const MAX_FAILURES: usize = 3;
/// A server as a [`TileRunner`], for one model: keeps up to `slots` connections open and gives up on the server after
/// [`MAX_FAILURES`] failed tiles (the caller runs those tiles itself).
pub struct Remote {
address: String,
token: String,
model_sha256: String,
tile: usize,
slots: usize,
timeout: Duration,
busy: AtomicUsize,
failures: AtomicUsize,
last_error: Mutex<Option<String>>,
pool: Mutex<Vec<(BufReader<TcpStream>, TcpStream)>>,
}
impl Remote {
/// `address` as `host[:port]` (default port [`DEFAULT_PORT`]; `tcp://` and a trailing `/` are ignored).
pub fn new(address: &str, token: &str, model_sha256: &str, tile: usize) -> Remote {
let a = address.trim().trim_start_matches("tcp://").trim_end_matches('/');
let has_port = a.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && !h.ends_with(':') && p.parse::<u16>().is_ok()) && !a.ends_with(']');
let address = if has_port { a.to_string() } else { format!("{a}:{DEFAULT_PORT}") };
Remote {
address,
token: token.into(),
model_sha256: model_sha256.to_ascii_lowercase(),
tile,
slots: 4,
timeout: Duration::from_secs(60),
busy: AtomicUsize::new(0),
failures: AtomicUsize::new(0),
last_error: Mutex::new(None),
pool: Mutex::new(Vec::new()),
}
}
pub fn with_slots(mut self, n: usize) -> Remote {
self.slots = n.clamp(1, 16);
self
}
pub fn address(&self) -> &str {
&self.address
}
/// Tiles it can take at once.
pub fn slots(&self) -> usize {
self.slots
}
/// Still used (fewer than [`MAX_FAILURES`] failed tiles).
pub fn healthy(&self) -> bool {
self.failures.load(Ordering::Relaxed) < MAX_FAILURES
}
pub fn failures(&self) -> usize {
self.failures.load(Ordering::Relaxed)
}
/// Why the last tile failed.
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn err(&self, reason: impl std::fmt::Display) -> Error {
Error::Runtime(format!("remote {}: {reason}", self.address))
}
fn connect(&self) -> Result<(BufReader<TcpStream>, TcpStream), Error> {
let addr = self.address.to_socket_addrs().map_err(|e| self.err(e))?.next().ok_or_else(|| self.err("the address doesn't resolve"))?;
let s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| self.err(e))?;
s.set_read_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
s.set_write_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
s.set_nodelay(true).map_err(|e| self.err(e))?;
let r = BufReader::new(s.try_clone().map_err(|e| self.err(e))?);
Ok((r, s))
}
fn reply(&self, r: &mut BufReader<TcpStream>) -> Result<Value, Error> {
let line = read_line(r).map_err(|e| self.err(e))?.ok_or_else(|| self.err("the connection closed"))?;
let v: Value = serde_json::from_str(&line).map_err(|_| self.err("malformed reply"))?;
if v.get("ok").and_then(Value::as_bool) != Some(true) {
return Err(self.err(v.get("error").and_then(Value::as_str).unwrap_or("request failed")));
}
Ok(v)
}
/// Greet the server: its device and model. Fails when it runs another model or tile size than this one.
pub fn hello(&self) -> Result<ServerInfo, Error> {
let (mut r, mut s) = self.connect()?;
writeln!(s, "{}", json!({"v": PROTOCOL, "token": self.token, "op": "hello"})).map_err(|e| self.err(e))?;
let v = self.reply(&mut r)?;
let m = v.get("model").cloned().unwrap_or_default();
let text = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let info = ServerInfo {
address: self.address.clone(),
device: text(&v, "device"),
model: ModelId { id: text(&m, "id"), version: text(&m, "version"), sha256: text(&m, "sha256") },
tile: field(&v, "tile").unwrap_or(0),
};
if !info.model.sha256.eq_ignore_ascii_case(&self.model_sha256) {
return Err(self.err(format!("it runs another model ({} {})", info.model.id, info.model.version)));
}
if info.tile != self.tile {
return Err(self.err(format!("it runs {}-cell tiles, this model {}", info.tile, self.tile)));
}
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
Ok(info)
}
/// Run a tile if a slot is free right now; `None` when all are busy or the server was given up (the caller runs
/// the tile elsewhere). A failure counts towards giving up on the server.
pub fn try_run(&self, input: &[f32]) -> Option<Result<Vec<f32>, Error>> {
if !self.healthy() {
return None;
}
if self.busy.fetch_add(1, Ordering::SeqCst) >= self.slots {
self.busy.fetch_sub(1, Ordering::SeqCst);
return None;
}
let r = self.run_now(input);
self.busy.fetch_sub(1, Ordering::SeqCst);
if let Err(e) = &r {
self.failures.fetch_add(1, Ordering::Relaxed);
*self.last_error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.to_string());
}
Some(r)
}
fn run_now(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
let t = self.tile;
if input.len() != 4 * t * t {
return Err(Error::Input(format!("a tile of {} samples for {t}-cell tiles", input.len())));
}
let pooled = self.pool.lock().unwrap_or_else(PoisonError::into_inner).pop();
let (mut r, mut s) = match pooled {
Some(c) => c,
None => self.connect()?,
};
let req = json!({"v": PROTOCOL, "token": self.token, "op": "run", "model": self.model_sha256, "tile": t});
write_payload(&mut s, req, input).map_err(|e| self.err(e))?;
s.flush().map_err(|e| self.err(e))?;
let v = self.reply(&mut r)?;
if field(&v, "tile") != Some(t) {
return Err(self.err("the answer is for another tile size"));
}
let bytes = field(&v, "bytes").ok_or_else(|| self.err("an answer without its size"))?;
let out = read_payload(&mut r, bytes, 3 * 4 * t * t).map_err(|e| self.err(e))?;
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
// the other computer's numbers go straight into the render: NaN or infinity (an f16 can carry
// both) is a failed tile, which `Shared` runs here instead
if !out.iter().all(|v| v.is_finite()) {
return Err(self.err("the answer holds values that aren't finite"));
}
Ok(out)
}
}
/// Tiles on the server when one of its slots is free, else (and for any tile it fails) on `local`: the server adds
/// speed to this computer's runner instead of replacing it.
pub struct Shared<'a> {
pub remote: &'a Remote,
pub local: &'a dyn TileRunner,
}
impl TileRunner for Shared<'_> {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
match self.remote.try_run(input) {
Some(Ok(out)) => Ok(out),
Some(Err(_)) | None => self.local.run(input),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "0123456789abcdef-test";
const T: usize = 8;
/// A stand-in model: each output pixel is its cell's first plane, times `k`.
fn model(k: f32) -> RunTile {
Arc::new(move |input: &[f32]| {
let t = T;
let mut out = vec![0f32; 3 * 4 * t * t];
for c in 0..3 {
for y in 0..2 * t {
for x in 0..2 * t {
out[(c * 2 * t + y) * 2 * t + x] = input[(y / 2) * t + x / 2] * k;
}
}
}
Ok(out)
})
}
fn id(sha: &str) -> ModelId {
ModelId { id: "test".into(), version: "1".into(), sha256: sha.into() }
}
fn start(sha: &str, run: RunTile) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap().to_string();
let opts = ServerOptions { token: TOKEN.into(), ..Default::default() };
let m = id(sha);
std::thread::spawn(move || serve(l, m, T, "CPU (test)".into(), run, opts, Arc::new(|_| {})));
addr
}
struct Local(RunTile, AtomicUsize);
impl TileRunner for Local {
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
self.1.fetch_add(1, Ordering::SeqCst);
(self.0)(input)
}
}
fn tile_input(seed: f32) -> Vec<f32> {
(0..4 * T * T).map(|i| (i as f32 * 0.37 + seed).sin() * 0.5 + 0.5).collect()
}
#[test]
fn remote_tiles_match_local_ones_within_half_float_rounding() {
let addr = start("aa11", model(2.0));
let remote = Remote::new(&addr, TOKEN, "AA11", T);
let info = remote.hello().unwrap();
assert_eq!((info.device.as_str(), info.tile), ("CPU (test)", T));
let input = tile_input(0.3);
let got = remote.try_run(&input).unwrap().unwrap();
let want = model(2.0)(&input).unwrap();
assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 2e-3));
}
#[test]
fn shared_runner_falls_back_and_gives_up_on_a_failing_server() {
let local = Local(model(2.0), AtomicUsize::new(0));
// nobody listening: every tile runs locally, and after a few the server is given up
let dead = Remote::new("127.0.0.1:1", TOKEN, "aa11", T);
let shared = Shared { remote: &dead, local: &local };
for i in 0..6 {
assert!(shared.run(&tile_input(i as f32)).is_ok());
}
assert_eq!(local.1.load(Ordering::SeqCst), 6);
assert!(!dead.healthy() && dead.last_error().is_some());
// a server with another model is refused at the greeting and per tile
let addr = start("bb22", model(2.0));
let other = Remote::new(&addr, TOKEN, "aa11", T);
assert!(other.hello().unwrap_err().to_string().contains("another model"));
assert!(other.try_run(&tile_input(0.0)).unwrap().unwrap_err().to_string().contains("model mismatch"));
// a wrong token
let addr = start("aa11", model(2.0));
assert!(Remote::new(&addr, "wrong-token-0000000", "aa11", T).hello().unwrap_err().to_string().contains("bad token"));
// all slots busy: the tile runs locally without waiting
let busy = Remote::new(&addr, TOKEN, "aa11", T).with_slots(1);
busy.busy.store(1, Ordering::SeqCst);
assert!(busy.try_run(&tile_input(0.0)).is_none());
}
#[test]
fn hostile_requests_are_refused_and_the_server_keeps_working() {
let addr = start("aa11", model(1.0));
let talk = |bytes: &[u8]| -> String {
let mut s = TcpStream::connect(&addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(bytes).unwrap();
let mut line = String::new();
let _ = BufReader::new(s).read_line(&mut line);
line
};
assert!(talk(b"not json\n").contains("malformed"));
let v2 = format!("{}\n", json!({"v": 2, "token": TOKEN, "op": "hello"}));
assert!(talk(v2.as_bytes()).contains("protocol"));
let big = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": 4096, "bytes": 10}));
assert!(talk(big.as_bytes()).contains("8-cell"));
// a payload that inflates past its tile
let bomb = miniz_oxide::deflate::compress_to_vec(&vec![0u8; 1 << 20], 9);
let hdr = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": T, "bytes": bomb.len()}));
assert!(talk(&[hdr.as_bytes(), &bomb].concat()).contains("false"));
assert!(Remote::new(&addr, TOKEN, "aa11", T).hello().is_ok());
assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "abcd"));
// a short token is refused when the server starts
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let opts = ServerOptions { token: "short".into(), ..Default::default() };
assert!(serve(l, id("aa11"), T, "x".into(), model(1.0), opts, Arc::new(|_| {})).is_err());
}
#[test]
fn packing_round_trips_and_addresses_are_normalised() {
let v: Vec<f32> = (0..4096).map(|i| 0.5 + ((i * 7919 % 101) as f32 - 50.0) * 0.002).collect();
let back = unpack(&pack(&v), v.len()).unwrap();
assert!(v.iter().zip(&back).all(|(a, b)| (a - b).abs() < 1e-3));
assert!(unpack(&pack(&v), v.len() + 1).is_err());
assert_eq!(Remote::new("tcp://100.1.2.3/", TOKEN, "a", T).address(), "100.1.2.3:7990");
assert_eq!(Remote::new("box:8000", TOKEN, "a", T).address(), "box:8000");
assert_eq!(Remote::new("[::1]", TOKEN, "a", T).address(), "[::1]:7990");
}
}
+86
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.
@@ -140,6 +177,13 @@ controls! {
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
"hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain;
"hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered;
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
@@ -501,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));
}
}
+80 -3
View File
@@ -38,11 +38,21 @@ impl DevelopSettings {
serde_json::from_value(v.clone())
}
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
/// Kelvin settings.
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
DevelopSettings::from_json(&v)
let mut out = DevelopSettings::from_json(&v)?;
if let Some(wb) = partial.get("wb").and_then(Value::as_object)
&& (wb.contains_key("temp") || wb.contains_key("tint"))
&& !wb.contains_key("scale")
{
out.wb.scale = WbScale::Legacy;
}
Ok(out)
}
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
@@ -56,11 +66,13 @@ impl DevelopSettings {
h
}
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values, and
/// the rendering process: that is not an edit).
pub fn is_unedited(&self) -> bool {
let mut a = self.clone();
let d = DevelopSettings::default();
a.wb = d.wb;
a.process = d.process;
a == d
}
@@ -130,6 +142,9 @@ impl DevelopSettings {
}
}
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests {
use super::*;
@@ -162,6 +177,68 @@ mod tests {
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
#[test]
fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() {
// settings from before the scale existed: no field, `Legacy`, and written back without it
let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}});
let s = DevelopSettings::from_json(&old).unwrap();
assert_eq!(s.wb.scale, WbScale::Legacy);
assert_eq!(s.to_json()["wb"], old["wb"]);
assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy);
assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none());
// written on the Kelvin scale: said so, and read back
let mut k = s.clone();
k.wb.scale = WbScale::Kelvin;
assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin"));
assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k);
assert_ne!(k.hash64(), s.hash64());
// an unknown scale from a newer build is an error, not a panic
assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err());
}
#[test]
fn a_partial_white_balance_without_a_scale_is_legacy() {
let mut base = DevelopSettings::default();
base.wb.scale = WbScale::Kelvin;
// an old preset's white balance lands on Kelvin settings: it keeps its relative meaning
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0);
assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0));
let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Legacy);
// one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
// the plain merge (`develop.merge`) follows the same rule
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy);
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin);
assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin);
}
#[test]
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
let s = DevelopSettings::default();
assert!(s.to_json().get("hdr").is_none(), "HDR off must not change the settings JSON or hash64");
let mut on = s.clone();
on.hdr.enabled = true;
assert!(on.to_json().get("hdr").is_some());
assert_ne!(on.hash64(), s.hash64());
assert_eq!(DevelopSettings::from_json(&on.to_json()).unwrap(), on);
}
#[test]
fn hdr_peak_is_finite_for_a_non_finite_headroom() {
let mut h = Hdr { enabled: true, max_ev: f64::NAN, ..Hdr::default() };
assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
h.max_ev = f64::INFINITY;
assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
h.max_ev = -3.0;
assert_eq!(h.peak(), 1.0);
}
#[test]
fn missing_fields_take_defaults_and_unknown_ignored() {
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
+28 -2
View File
@@ -3,6 +3,10 @@
//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it
//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current
//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]).
//!
//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets
//! made from a photo never carry it, so the photos they change keep their own. A partial that names
//! `process` itself (Apply Settings JSON) sets it like any other field.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -98,7 +102,7 @@ impl SettingsGroup {
SettingsGroup::Profile => &["profile"],
SettingsGroup::Treatment => &["treatment"],
SettingsGroup::WhiteBalance => &["wb"],
SettingsGroup::Light => &["light"],
SettingsGroup::Light => &["light", "hdr"],
SettingsGroup::ToneCurve => &["curve"],
SettingsGroup::Color => &["color"],
SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"],
@@ -162,7 +166,9 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
Value::Object(out)
}
(Value::Number(b), Value::Number(p)) => {
// Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would
// not deserialize as an integer, and the whole preset would silently not apply.
(Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => {
let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0));
serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null)
}
@@ -181,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let base = s.to_json();
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
for c in crate::controls::CONTROLS {
if let Some(v) = crate::controls::get(&out, c.id) {
@@ -267,6 +274,25 @@ mod tests {
assert_eq!(lots.light.exposure, 5.0);
}
/// A preset with the Grain group (its seed is an integer) used to do nothing at any amount
/// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped.
#[test]
fn amount_keeps_integer_fields_whole() {
let mut a = DevelopSettings::default();
a.light.exposure = 1.0;
a.grain.amount = 40.0;
a.grain.seed = 12345;
let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed");
let base = DevelopSettings::default();
for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] {
let out = p.apply(&base, amount);
assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure);
assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount);
assert_eq!(out.grain.seed, seed, "{amount}: seed");
}
}
#[test]
fn deep_merge_objects() {
let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});
+253 -6
View File
@@ -1,19 +1,122 @@
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
//! older/newer files load (unknown fields are ignored, missing fields take defaults). The one
//! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were
//! made with.
use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
/// The rendering process a photo's settings are interpreted by (what Lightroom calls the process
/// version). A change that would alter how existing settings render ships as a new process, so a
/// photo keeps its look until someone updates it (`develop.updateProcess`). See
/// `docs/process-versions.md`.
///
/// Stored as a plain number: one written by a newer LightCraft loads, is kept as it is, and
/// renders with the newest process this build knows ([`ProcessVersion::process`]). Reading is
/// lenient: a whole number from 0 to 2^32 - 1 (also written as `2.0`) is that process; anything
/// else (negative, fractional, too large, text, null, a list…) reads as V1, like a missing field,
/// so a damaged value never costs the rest of the settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ProcessVersion(pub u32);
impl<'de> Deserialize<'de> for ProcessVersion {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Whole(u64),
Number(f64),
Other(serde::de::IgnoredAny),
}
Ok(match Wire::deserialize(d)? {
Wire::Whole(n) => u32::try_from(n).map_or_else(|_| ProcessVersion::legacy(), ProcessVersion),
Wire::Number(x) if x.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&x) => ProcessVersion(x as u32),
Wire::Number(_) | Wire::Other(_) => ProcessVersion::legacy(),
})
}
}
impl ProcessVersion {
/// The first process: LightCraft's rendering from before process versions existed. Frozen.
pub const V1: ProcessVersion = Process::V1.version();
/// The process new photos and Reset get.
pub const LATEST: ProcessVersion = Process::LATEST.version();
/// The process of settings saved without one (catalogs, snapshots, XMP sidecars and browser
/// libraries written before process versions existed): V1, whatever [`Self::LATEST`] becomes.
pub fn legacy() -> ProcessVersion {
ProcessVersion::V1
}
/// Saved settings leave the field out for V1, so files and preview-cache keys of settings made
/// before process versions existed stay byte for byte what they were.
pub fn is_legacy(&self) -> bool {
*self == ProcessVersion::legacy()
}
/// The process this build renders with: the newest one it knows that isn't newer than this
/// number (a number from a newer LightCraft renders with [`Process::LATEST`]; one below V1,
/// which no LightCraft writes, with V1).
pub fn process(self) -> Process {
Process::ALL.into_iter().rev().find(|p| p.version() <= self).unwrap_or(Process::V1)
}
/// A process this build implements.
pub fn is_known(self) -> bool {
Process::ALL.iter().any(|p| p.version() == self)
}
/// Older than [`Self::LATEST`]: Update to Current Process applies.
pub fn is_outdated(self) -> bool {
self < ProcessVersion::LATEST
}
}
/// A rendering process this build implements. Matches on it are exhaustive, so adding a process
/// makes the compiler point at every stage whose behaviour depends on it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Process {
/// The rendering of LightCraft before process versions existed (2026-10).
V1,
/// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base
/// is taken of a fine median of log luminance (issue #632).
V2,
}
impl Process {
/// Every process, oldest first.
pub const ALL: [Process; 2] = [Process::V1, Process::V2];
pub const LATEST: Process = Process::V2;
pub const fn version(self) -> ProcessVersion {
match self {
Process::V1 => ProcessVersion(1),
Process::V2 => ProcessVersion(2),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DevelopSettings {
/// Schema version of this structure (not the rendering process, see `process`).
pub version: u32,
/// The rendering process ([`ProcessVersion`]): the latest for new settings, V1 for settings
/// saved without the field (the field-level default: not the struct's `Default`) or with a
/// value that isn't a process number.
#[serde(default = "ProcessVersion::legacy", skip_serializing_if = "ProcessVersion::is_legacy")]
pub process: ProcessVersion,
pub profile: Profile,
pub treatment: Treatment,
pub wb: WhiteBalance,
pub light: Light,
/// HDR editing: highlights above SDR white, and the SDR rendition derived from them. Not written while it is
/// the default, so settings without HDR keep their JSON and `hash64` (and every cached preview stays valid).
#[serde(skip_serializing_if = "Hdr::is_default")]
pub hdr: Hdr,
pub curve: ToneCurve,
pub color: ColorAdj,
pub mixer: Mixer,
@@ -44,10 +147,12 @@ impl Default for DevelopSettings {
fn default() -> Self {
Self {
version: SCHEMA_VERSION,
process: ProcessVersion::LATEST,
profile: Profile::default(),
treatment: Treatment::Color,
wb: WhiteBalance::default(),
light: Light::default(),
hdr: Hdr::default(),
curve: ToneCurve::default(),
color: ColorAdj::default(),
mixer: Mixer::default(),
@@ -141,7 +246,13 @@ impl WbMode {
WbMode::Custom => "Custom",
}
}
/// Preset temperature/tint for raw files (K, tint).
/// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 /
/// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for
/// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction
/// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta
/// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same
/// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws
/// without matrices) `develop.wb` scales them around 6500 K = as shot.
pub fn preset(self) -> Option<(f64, f64)> {
match self {
WbMode::Daylight => Some((5500.0, 10.0)),
@@ -155,9 +266,35 @@ impl WbMode {
}
}
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on.
///
/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin;
/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0
/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them
/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's
/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the
/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WbScale {
/// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot)
/// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written.
#[default]
Legacy,
/// Absolute Kelvin and tint, as Lightroom writes them for raws.
Kelvin,
}
impl WbScale {
fn is_legacy(&self) -> bool {
*self == WbScale::Legacy
}
}
/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the
/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is
/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a
/// relative −100..100 scale for those, like Lightroom).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WhiteBalance {
@@ -165,11 +302,17 @@ pub struct WhiteBalance {
pub temp: f64,
/// Correction direction: negative adds green, positive adds magenta.
pub tint: f64,
/// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings
/// from before it existed read and write unchanged.
#[serde(skip_serializing_if = "WbScale::is_legacy")]
pub scale: WbScale,
}
impl Default for WhiteBalance {
fn default() -> Self {
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
// (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the
// scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`)
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy }
}
}
@@ -555,6 +698,33 @@ impl Default for Optics {
pub struct EmbeddedLens {
pub warp: Option<EmbeddedWarp>,
pub vignette: Option<EmbeddedVignette>,
/// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps
/// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode.
pub stages: [Option<EmbeddedWarpStage>; WARP_STAGES],
}
/// Most warp stages an [`EmbeddedLens`] chain keeps.
pub const WARP_STAGES: usize = 3;
/// One DNG `WarpRectilinear2` (or a `WarpRectilinear` in a chain with one): per plane (R, G, B)
/// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]`. For an output point at offset
/// `d = (p − center) / radius` with `r = |d|` and `f = Σ krₚ·clamp(r, min, max)ᵖ`, the source point is
/// `center + radius · (d·f + tangential(d))`, or `d / f` for the radial part when `reciprocal`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedWarpStage {
pub planes: [[f64; 19]; 3],
/// Optical centre, normalized to the oriented image (0..1).
pub center: Point,
/// Normalisation radius as a fraction of the oriented image's long edge.
pub radius: f64,
pub reciprocal: bool,
}
impl EmbeddedLens {
/// Whether a `WarpRectilinear2` chain is set.
pub fn has_stages(&self) -> bool {
self.stages.iter().any(Option::is_some)
}
}
/// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at
@@ -862,6 +1032,8 @@ pub enum SpotMode {
Remove,
Heal,
Clone,
/// Content-aware: the spot is rebuilt from patches of its surroundings (no single source).
Fill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -931,3 +1103,78 @@ impl Enhance {
self.denoise_on.unwrap_or(self.denoise > 0.0)
}
}
/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0)
/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's
/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the
/// `sdr_*` offsets applied.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Hdr {
pub enabled: bool,
/// Headroom limit: stops above SDR white the HDR render may reach.
pub max_ev: f64,
/// SDR rendition offsets (−100..100): brightness (±1 EV), then added to the matching sliders.
pub sdr_brightness: f64,
pub sdr_contrast: f64,
pub sdr_highlights: f64,
pub sdr_shadows: f64,
pub sdr_whites: f64,
pub sdr_clarity: f64,
}
impl Default for Hdr {
fn default() -> Self {
Self {
enabled: false,
max_ev: Hdr::DEFAULT_MAX_EV,
sdr_brightness: 0.0,
sdr_contrast: 0.0,
sdr_highlights: 0.0,
sdr_shadows: 0.0,
sdr_whites: 0.0,
sdr_clarity: 0.0,
}
}
}
impl Hdr {
pub const DEFAULT_MAX_EV: f64 = 3.0;
pub const MAX_EV_LIMIT: f64 = 5.0;
/// Whether these are the default HDR settings (HDR off, nothing changed).
pub fn is_default(&self) -> bool {
*self == Hdr::default()
}
/// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). A non-finite headroom
/// (a damaged file or argument) falls back to the default, so the peak is always finite and at least 1.
pub fn peak(&self) -> f32 {
if !self.enabled {
return 1.0;
}
let ev = if self.max_ev.is_finite() { self.max_ev } else { Hdr::DEFAULT_MAX_EV };
(ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2()
}
}
impl DevelopSettings {
/// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the
/// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity.
pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> {
if !self.hdr.enabled {
return std::borrow::Cow::Borrowed(self);
}
let h = self.hdr;
let mut s = self.clone();
s.hdr.enabled = false;
let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0);
s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0);
s.light.contrast = add(s.light.contrast, h.sdr_contrast);
s.light.highlights = add(s.light.highlights, h.sdr_highlights);
s.light.shadows = add(s.light.shadows, h.sdr_shadows);
s.light.whites = add(s.light.whites, h.sdr_whites);
s.effects.clarity = add(s.effects.clarity, h.sdr_clarity);
std::borrow::Cow::Owned(s)
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Process versions: settings saved before they existed stay on V1, new settings get the latest,
//! and numbers this build doesn't know load without harm.
use serde_json::{Value, json};
use crate::*;
/// Complete settings as LightCraft wrote them before process versions existed (main at 8f924a9:
/// `serde_json::to_string` of a raw photo with tone, colour, curve, grading, effects and a radial
/// mask), one top-level key per line. There is no `process` field.
const LEGACY: &str = r#"
{
"version": 1,
"profile": {"id": "lc.color", "amount": 100.0},
"treatment": "color",
"wb": {"mode": "asShot", "temp": 5200.0, "tint": 4.0},
"light": {"exposure": 0.4, "contrast": 30.0, "highlights": -60.0, "shadows": 40.0, "whites": 15.0, "blacks": -20.0},
"curve": {"highlights": -10.0, "lights": 0.0, "darks": 10.0, "shadows": 0.0, "split_shadows": 25.0, "split_mid": 50.0, "split_highlights": 75.0, "master": [{"x": 0.0, "y": 0.05}, {"x": 0.5, "y": 0.55}, {"x": 1.0, "y": 0.95}], "red": [], "green": [], "blue": [], "refine_saturation": 100.0},
"color": {"vibrance": 20.0, "saturation": -5.0},
"mixer": {"red": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "orange": {"hue": 0.0, "sat": 0.0, "lum": 15.0}, "yellow": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "green": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "aqua": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blue": {"hue": 0.0, "sat": -30.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "magenta": {"hue": 0.0, "sat": 0.0, "lum": 0.0}},
"point_colors": [],
"bw_mix": {"red": 0.0, "orange": 0.0, "yellow": 0.0, "green": 0.0, "aqua": 0.0, "blue": 0.0, "purple": 0.0, "magenta": 0.0},
"grading": {"shadows": {"hue": 220.0, "sat": 20.0, "lum": -5.0}, "midtones": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 15.0, "lum": 5.0}, "global": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blending": 50.0, "balance": 0.0},
"effects": {"texture": 20.0, "clarity": 25.0, "dehaze": 15.0},
"vignette": {"amount": -25.0, "midpoint": 50.0, "roundness": 0.0, "feather": 50.0, "highlights": 0.0, "style": "highlightPriority"},
"grain": {"amount": 20.0, "size": 25.0, "roughness": 50.0, "seed": 0},
"detail": {"sharpen_amount": 40.0, "sharpen_radius": 1.0, "sharpen_detail": 25.0, "sharpen_masking": 0.0, "nr_luminance": 20.0, "nr_detail": 50.0, "nr_contrast": 0.0, "nr_color": 25.0, "nr_color_detail": 50.0, "nr_color_smoothness": 50.0},
"optics": {"remove_ca": false, "lens_profile": false, "profile_distortion": 100.0, "profile_vignetting": 100.0, "defringe_purple_amount": 0.0, "defringe_purple_hue_lo": 30.0, "defringe_purple_hue_hi": 70.0, "defringe_green_amount": 0.0, "defringe_green_hue_lo": 40.0, "defringe_green_hue_hi": 60.0, "distortion": 0.0, "vignetting": 0.0, "vignetting_midpoint": 50.0, "ca_red": 0.0, "ca_blue": 0.0},
"geometry": {"upright": "off", "guides": [], "upright_transform": null, "vertical": 0.0, "horizontal": 0.0, "rotate": 0.0, "aspect": 0.0, "scale": 100.0, "offset_x": 0.0, "offset_y": 0.0, "constrain_crop": false},
"crop": {"geometry": {"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}, "aspect": null, "flip_h": false, "flip_v": false},
"orientation": "Normal",
"masks": [{"id": 1, "name": "Mask 1", "visible": true, "invert": false, "components": [{"op": "add", "invert": false, "shape": {"kind": "radial", "center": {"x": 0.4, "y": 0.5}, "rx": 0.3, "ry": 0.2, "angle": 15.0, "feather": 60.0, "invert": false}}], "adjust": {"temp": 10.0, "tint": 0.0, "exposure": 0.5, "contrast": 0.0, "highlights": 0.0, "shadows": 0.0, "whites": 0.0, "blacks": 0.0, "texture": 0.0, "clarity": 30.0, "dehaze": 0.0, "hue": 0.0, "saturation": 0.0, "sharpness": 0.0, "noise": 0.0, "moire": 0.0, "defringe": 0.0, "color_hue": 0.0, "color_sat": 0.0, "amount": 100.0}}],
"spots": [],
"red_eye": [],
"lens_blur": {"enabled": false, "amount": 0.0, "focal_distance": 0.0, "focal_range": 0.0},
"enhance": {"denoise": 0.0, "raw_details": false, "super_resolution": false},
"calibration": {"shadows_tint": 0.0, "red_hue": 0.0, "red_sat": 0.0, "green_hue": 0.0, "green_sat": 0.0, "blue_hue": 0.0, "blue_sat": 0.0},
"disabled_sections": []
}
"#;
fn legacy() -> Value {
serde_json::from_str(LEGACY).unwrap()
}
#[test]
fn settings_saved_before_process_versions_load_as_v1() {
let v = legacy();
assert!(v.get("process").is_none(), "the fixture predates the field");
let s = DevelopSettings::from_json(&v).unwrap();
// V1 as a number, not `LATEST`: once a newer process is the latest, this is what keeps every
// existing catalog, sidecar and preset on the rendering it was made with
assert_eq!(s.process, ProcessVersion(1));
assert_eq!(s.process, ProcessVersion::legacy());
assert_eq!((s.light.exposure, s.light.highlights, s.masks.len(), s.detail.sharpen_amount), (0.4, -60.0, 1, 40.0));
// the smallest legacy file too, and settings nested in other records (history, versions)
assert_eq!(DevelopSettings::from_json(&json!({})).unwrap().process, ProcessVersion(1));
let nested: Vec<DevelopSettings> = serde_json::from_value(json!([{}, {"light": {"exposure": 1.0}}])).unwrap();
assert!(nested.iter().all(|s| s.process == ProcessVersion(1)));
// saved again, they gain no field: files and preview-cache keys (`hash64`) stay as they were
assert!(s.to_json().get("process").is_none());
}
#[test]
fn new_settings_get_the_latest_process() {
assert_eq!(DevelopSettings::default().process, ProcessVersion::LATEST);
assert_eq!(DevelopSettings::for_raw(5200.0, 4.0).process, ProcessVersion::LATEST);
assert_eq!(ProcessVersion::LATEST, Process::LATEST.version());
assert_eq!(ProcessVersion::V1, ProcessVersion(1));
// the processes are listed oldest first, the latest last, each with its own number
let versions: Vec<u32> = Process::ALL.iter().map(|p| p.version().0).collect();
assert!(versions.windows(2).all(|w| w[0] < w[1]), "{versions:?}");
assert_eq!(Process::ALL.last(), Some(&Process::LATEST));
assert!(Process::ALL.iter().all(|p| p.version().process() == *p && p.version().is_known()));
}
#[test]
fn v1_is_left_out_when_saving_and_other_processes_are_kept() {
let mut s = DevelopSettings::from_json(&legacy()).unwrap();
assert!(s.to_json().get("process").is_none());
let before = s.hash64();
s.process = ProcessVersion(2);
let v = s.to_json();
assert_eq!(v["process"], json!(2));
assert_eq!(DevelopSettings::from_json(&v).unwrap().process, ProcessVersion(2));
assert_ne!(s.hash64(), before, "another process is another preview");
}
#[test]
fn unknown_process_numbers_load_and_render_with_the_newest_known() {
// written by a newer LightCraft: kept as it is, rendered with the latest process this build has
let s = DevelopSettings::from_json(&json!({"process": 7, "light": {"exposure": 0.5}})).unwrap();
assert_eq!(s.process, ProcessVersion(7));
assert_eq!(s.process.process(), Process::LATEST);
assert!(!s.process.is_known());
assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process");
assert_eq!(s.to_json()["process"], json!(7));
assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(7));
// below V1 (no LightCraft writes it): rendered as V1, and updatable
let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process;
assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true));
let max = DevelopSettings::from_json(&json!({"process": u32::MAX})).unwrap().process;
assert_eq!(max.process(), Process::LATEST);
// a whole number written as a float is that number
assert_eq!(DevelopSettings::from_json(&json!({"process": 2.0})).unwrap().process, ProcessVersion(2));
}
#[test]
fn a_damaged_process_value_reads_as_v1_and_keeps_the_edits() {
let too_big = u64::from(u32::MAX) + 1;
for bad in [
json!(-1),
json!(1.5),
json!(-0.5),
json!(1e300),
json!(too_big),
json!(u64::MAX),
json!("2"),
json!(null),
json!(true),
json!([2]),
json!({"v": 2}),
] {
let mut v = legacy();
v["process"] = bad.clone();
let s = DevelopSettings::from_json(&v).unwrap_or_else(|e| panic!("{bad}: {e}"));
assert_eq!(s.process, ProcessVersion::V1, "{bad}");
assert_eq!((s.light.exposure, s.masks.len(), s.curve.master.len()), (0.4, 1, 3), "{bad}: the rest of the settings load");
// nested in other records too (history, versions, the catalog)
let nested: Vec<DevelopSettings> = serde_json::from_value(json!([v])).unwrap();
assert_eq!(nested[0].process, ProcessVersion::V1);
}
}
#[test]
fn the_process_is_not_an_edit_nor_part_of_copied_settings() {
let mut s = DevelopSettings { process: ProcessVersion(LATEST_PLUS_ONE), ..Default::default() };
assert!(s.is_unedited(), "a photo on another process is not edited for that");
s.light.exposure = 1.0;
assert!(!s.is_unedited());
// resetting a section or a slider leaves the process alone
s.reset_section(Section::Light);
assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE));
// copy / paste / sync / presets: no group carries the process, so the target keeps its own
assert!(SettingsGroup::ALL.iter().all(|g| !g.keys().contains(&"process")));
s.light.exposure = 1.0;
let copied = extract_groups(&s, &SettingsGroup::ALL);
assert!(copied.get("process").is_none());
let target = DevelopSettings::from_json(&legacy()).unwrap();
let pasted = apply_partial(&target, &copied, 1.0);
assert_eq!((pasted.process, pasted.light.exposure), (ProcessVersion(1), 1.0));
// (at another amount, without Grain: its integer seed doesn't survive scaling, a separate issue)
let groups: Vec<SettingsGroup> = SettingsGroup::ALL.into_iter().filter(|g| *g != SettingsGroup::Grain).collect();
let preset = Preset::from_settings("p", "P", "User", &s, &groups);
let half = preset.apply(&target, 0.5);
assert_eq!((half.process, half.light.exposure), (ProcessVersion(1), 0.7));
// a partial that names a process explicitly (Apply Settings JSON) sets it
assert_eq!(target.merged(&json!({"process": LATEST_PLUS_ONE})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE));
}
/// A process other than the latest: a newer LightCraft's number (no older one than V1 exists).
const LATEST_PLUS_ONE: u32 = ProcessVersion::LATEST.0 + 1;
+6
View File
@@ -24,9 +24,11 @@ lightcraft-raw = { workspace = true }
lightcraft-denoise-core = { workspace = true }
lightcraft-denoise = { workspace = true, optional = true }
lightcraft-meta = { workspace = true }
lightcraft-lensdb = { workspace = true }
lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
lightcraft-geocode = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -49,5 +51,9 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
# HDR JPEG XL tests read the file's colour encoding
jxl-oxide = { workspace = true }
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
lightcraft-heif = { workspace = true, features = ["testdata"] }
libc = { workspace = true }
lightcraft-tiff = { workspace = true }
+9 -7
View File
@@ -247,18 +247,20 @@ fn main() {
}
}
if run("batch") && args.len() > 1 {
// full-size JPEG export of all the files given (decode + render + encode)
use lightcraft_engine::export::export_photo;
// full-size JPEG export of all the files given (decode + render + encode), as the Export
// dialog runs it: several photos side by side (`export_parallelism`), files discarded
use lightcraft_engine::export::{Destination, export_batch};
let mut session = lightcraft_engine::Session::new().with_fs();
let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import");
let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect();
let items: Vec<_> = ids.iter().enumerate().map(|(i, id)| (*id, i + 1)).collect();
let o = ExportOptions::default();
row(&format!("batch export of {} files (decode+render+JPEG):", items.len()), &mut |g| {
let lanes = lightcraft_engine::export::export_parallelism(ids.len());
row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| {
lightcraft_engine::gpu::set_enabled(g);
for &(id, seq) in &items {
drop(export_photo(&mut session, id, &o, seq).expect("export"));
session.media.forget(id); // as in a batch: every original is decoded once
let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export");
assert_eq!(files.len(), ids.len());
for &id in &ids {
session.media.forget(id); // every original is decoded once per run
}
});
lightcraft_engine::gpu::set_enabled(true);
+438
View File
@@ -0,0 +1,438 @@
//! Background tasks in flight (issue #345): imports, exports, preview builds, downloads, the face scan… Each one
//! registers here while it runs, so every frontend can show them in one place (the desktop app's activity stack) and
//! cancel the ones that can be cancelled (`activity.list`, `activity.cancel`).
//!
//! A job holds a [`TaskGuard`] for as long as it runs; dropping it — when the job ends, fails or its thread unwinds
//! from a panic — removes the row, so a dead worker never leaves a stuck bar. Jobs keep the cancel flag they already
//! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the
//! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a
//! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use serde::Serialize;
/// The longest label or detail kept, in characters (a file name can be anything).
const MAX_TEXT: usize = 200;
/// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it.
#[derive(Clone, Default)]
pub struct Activity {
inner: Arc<Inner>,
}
#[derive(Default)]
struct Inner {
/// Oldest first.
tasks: Mutex<Vec<Arc<Entry>>>,
next: AtomicU64,
}
/// How a task can be cancelled.
pub enum Cancel {
/// Not at all (no ✕): the face scan, AI Denoise.
No,
/// With a flag of its own ([`TaskGuard::cancel_flag`]).
Yes,
/// With the job's existing flag.
Flag(Arc<AtomicBool>),
}
/// How the frontend words `done` / `total`: "3 of 25", "12 of 340 MB", "40 %".
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Unit {
#[default]
Count,
Bytes,
Percent,
}
impl Unit {
fn from_u8(v: u8) -> Unit {
match v {
1 => Unit::Bytes,
2 => Unit::Percent,
_ => Unit::Count,
}
}
fn to_u8(self) -> u8 {
match self {
Unit::Count => 0,
Unit::Bytes => 1,
Unit::Percent => 2,
}
}
}
/// One task as the frontends see it (`activity.list`).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskInfo {
pub id: u64,
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
pub done: u64,
/// 0 while the amount of work isn't known yet (an indeterminate bar).
pub total: u64,
pub unit: Unit,
/// The file, photo or model being worked on, if any.
pub detail: String,
pub cancellable: bool,
/// Cancel was asked for; the job stops at its next check.
pub cancelling: bool,
pub age_ms: u64,
}
struct Entry {
id: u64,
kind: &'static str,
label: String,
started: web_time::Instant,
done: AtomicU64,
total: AtomicU64,
unit: AtomicU8,
detail: Mutex<String>,
cancel: Option<Arc<AtomicBool>>,
cancellable: AtomicBool,
/// Held while a cancel is decided ([`Activity::cancel`], [`Activity::cancel_all`]) and while
/// cancelling is switched ([`TaskGuard::set_cancellable`], [`TaskHandle::set_cancellable`]): a
/// step that turns it off and then reads the cancel flag either sees a cancel that was
/// accepted, or makes the cancel fail. Without it a cancel could pass the check, the step
/// start, and the flag be set after the step's last look at it: accepted but ignored.
gate: Mutex<()>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
fn capped(text: &str) -> String {
text.chars().take(MAX_TEXT).collect()
}
impl Entry {
fn set_cancellable(&self, yes: bool) {
let _gate = lock(&self.gate);
self.cancellable.store(yes, Ordering::Relaxed);
}
/// Set the cancel flag if the task can be cancelled now (see [`Entry::gate`]).
fn try_cancel(&self) -> bool {
let _gate = lock(&self.gate);
match &self.cancel {
Some(flag) if self.is_cancellable() => {
flag.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
fn is_cancellable(&self) -> bool {
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
}
fn is_cancelled(&self) -> bool {
self.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))
}
fn progress(&self, done: u64, total: u64) {
self.total.store(total, Ordering::Relaxed);
self.done.store(if total > 0 { done.min(total) } else { done }, Ordering::Relaxed);
}
fn detail(&self, text: &str) {
*lock(&self.detail) = capped(text);
}
fn info(&self) -> TaskInfo {
TaskInfo {
id: self.id,
kind: self.kind,
label: self.label.clone(),
done: self.done.load(Ordering::Relaxed),
total: self.total.load(Ordering::Relaxed),
unit: Unit::from_u8(self.unit.load(Ordering::Relaxed)),
detail: lock(&self.detail).clone(),
cancellable: self.is_cancellable(),
cancelling: self.is_cancelled(),
age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
}
}
}
impl Activity {
/// Register a task; it is listed until the returned guard is dropped. `label` is English (the frontend
/// translates it).
pub fn start(&self, kind: &'static str, label: &str, cancel: Cancel) -> TaskGuard {
let id = self.inner.next.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let cancel = match cancel {
Cancel::No => None,
Cancel::Yes => Some(Arc::new(AtomicBool::new(false))),
Cancel::Flag(flag) => Some(flag),
};
let entry = Arc::new(Entry {
id,
kind,
label: capped(label),
started: web_time::Instant::now(),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
unit: AtomicU8::new(Unit::Count.to_u8()),
detail: Mutex::new(String::new()),
cancellable: AtomicBool::new(cancel.is_some()),
cancel,
gate: Mutex::new(()),
});
lock(&self.inner.tasks).push(entry.clone());
TaskGuard { activity: self.clone(), entry }
}
/// Every task in flight, oldest first.
pub fn list(&self) -> Vec<TaskInfo> {
lock(&self.inner.tasks).iter().map(|e| e.info()).collect()
}
/// Ask task `id` to stop (it stops at its next check). An unknown id (the task may have just ended) or a task
/// that can't be cancelled is an error.
pub fn cancel(&self, id: u64) -> Result<(), String> {
let tasks = lock(&self.inner.tasks);
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
if entry.try_cancel() { Ok(()) } else { Err(format!("{} can't be cancelled", entry.label)) }
}
/// Ask every cancellable task to stop; returns how many were asked.
pub fn cancel_all(&self) -> usize {
let tasks = lock(&self.inner.tasks);
tasks.iter().filter(|e| e.try_cancel()).count()
}
/// Tasks that quitting would cut short: cancellable ones not already stopping.
pub fn running_cancellable(&self) -> Vec<TaskInfo> {
lock(&self.inner.tasks).iter().filter(|e| e.is_cancellable() && !e.is_cancelled()).map(|e| e.info()).collect()
}
}
/// A task's place in the list, held by whoever owns the job; dropping it removes the row.
pub struct TaskGuard {
activity: Activity,
entry: Arc<Entry>,
}
impl TaskGuard {
pub fn id(&self) -> u64 {
self.entry.id
}
/// `done` of `total` (`total` 0 = not known yet). `done` is kept at most `total`.
pub fn progress(&self, done: u64, total: u64) {
self.entry.progress(done, total);
}
/// The file, photo or model being worked on (a name, not a sentence).
pub fn detail(&self, text: &str) {
self.entry.detail(text);
}
pub fn set_unit(&self, unit: Unit) {
self.entry.unit.store(unit.to_u8(), Ordering::Relaxed);
}
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
pub fn set_cancellable(&self, yes: bool) {
self.entry.set_cancellable(yes);
}
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
self.entry.cancel.clone()
}
pub fn is_cancelled(&self) -> bool {
self.entry.is_cancelled()
}
/// For progress from another thread; it does not keep the row.
pub fn handle(&self) -> TaskHandle {
TaskHandle { entry: self.entry.clone() }
}
}
impl Drop for TaskGuard {
fn drop(&mut self) {
let id = self.entry.id;
lock(&self.activity.inner.tasks).retain(|e| e.id != id);
}
}
impl std::fmt::Debug for TaskGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskGuard").field("id", &self.entry.id).field("kind", &self.entry.kind).finish()
}
}
/// Progress for a task from another thread (see [`TaskGuard::handle`]).
#[derive(Clone)]
pub struct TaskHandle {
entry: Arc<Entry>,
}
impl TaskHandle {
pub fn progress(&self, done: u64, total: u64) {
self.entry.progress(done, total);
}
pub fn detail(&self, text: &str) {
self.entry.detail(text);
}
pub fn is_cancelled(&self) -> bool {
self.entry.is_cancelled()
}
/// [`TaskGuard::set_cancellable`] from the worker, at the moment its step starts (an export
/// handing its files to Apple Photos, which can't be stopped once asked). Read the cancel flag
/// after turning it off: a cancel accepted before is seen then, any later one is refused.
pub fn set_cancellable(&self, yes: bool) {
self.entry.set_cancellable(yes);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn guard_drop_removes_the_row() {
let a = Activity::default();
let g = a.start("export", "Exporting", Cancel::Yes);
assert_eq!(a.list().len(), 1);
drop(g);
assert!(a.list().is_empty());
}
#[test]
fn guard_drop_on_panic_unwind_removes_the_row() {
let a = Activity::default();
let a2 = a.clone();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _g = a2.start("import", "Importing", Cancel::Yes);
panic!("synthetic worker panic");
}));
assert!(r.is_err());
assert!(a.list().is_empty());
}
#[test]
fn cancel_sets_the_adopted_flag() {
let a = Activity::default();
let flag = Arc::new(AtomicBool::new(false));
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
a.cancel(g.id()).unwrap();
assert!(flag.load(Ordering::Relaxed) && g.is_cancelled());
assert!(a.list()[0].cancelling);
}
#[test]
fn cancel_errors_instead_of_panicking() {
let a = Activity::default();
assert!(a.cancel(42).is_err());
let g = a.start("faces", "Finding faces", Cancel::No);
assert!(a.cancel(g.id()).is_err());
let h = a.start("lightroom", "Importing Lightroom catalog", Cancel::Yes);
h.set_cancellable(false);
assert!(a.cancel(h.id()).is_err());
assert!(!h.is_cancelled());
}
#[test]
fn cancel_all_counts_only_cancellable() {
let a = Activity::default();
let (_x, _y, _z) = (a.start("export", "E", Cancel::Yes), a.start("import", "I", Cancel::Yes), a.start("faces", "F", Cancel::No));
assert_eq!(a.cancel_all(), 2);
assert!(a.running_cancellable().is_empty());
}
#[test]
fn progress_is_clamped_and_detail_capped() {
let a = Activity::default();
let g = a.start("export", "E", Cancel::Yes);
g.progress(12, 10);
g.detail(&"é".repeat(300));
let t = &a.list()[0];
assert_eq!((t.done, t.total), (10, 10));
assert_eq!(t.detail.chars().count(), 200);
g.progress(5, 0); // indeterminate keeps done as given
assert_eq!(a.list()[0].total, 0);
assert_eq!(a.list()[0].unit, Unit::Count);
g.set_unit(Unit::Bytes);
assert_eq!(a.list()[0].unit, Unit::Bytes);
}
#[test]
fn ids_are_unique_and_list_is_oldest_first() {
let a = Activity::default();
let (g1, g2) = (a.start("export", "A", Cancel::Yes), a.start("import", "B", Cancel::Yes));
assert!(g2.id() > g1.id());
assert_eq!(a.list().iter().map(|t| t.id).collect::<Vec<_>>(), vec![g1.id(), g2.id()]);
}
#[test]
fn poisoned_lock_still_works() {
let a = Activity::default();
let a2 = a.clone();
let _ = std::thread::spawn(move || {
let _held = a2.inner.tasks.lock().unwrap();
panic!("synthetic panic while holding the task list");
})
.join();
assert!(a.inner.tasks.is_poisoned());
let _g = a.start("export", "E", Cancel::Yes);
assert_eq!(a.list().len(), 1);
}
/// Codex review of #643: a worker that turns cancelling off and then reads the cancel flag
/// (an export handing its files to Apple Photos) must either see a cancel that was accepted or
/// make it fail, never let one be accepted and then ignored. Here the test holds the task's gate
/// as a cancel does while it decides: the worker's switch waits for it, and then sees the cancel.
#[test]
fn a_step_that_turns_cancelling_off_sees_or_refuses_every_cancel() {
let a = Activity::default();
let flag = Arc::new(AtomicBool::new(false));
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
let handle = g.handle();
let gate = lock(&g.entry.gate); // a cancel deciding
let (tx, rx) = std::sync::mpsc::channel();
let (h, f) = (handle.clone(), flag.clone());
let worker = std::thread::spawn(move || {
h.set_cancellable(false);
tx.send(f.load(Ordering::Relaxed)).unwrap();
});
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "the switch waits for the cancel");
flag.store(true, Ordering::Relaxed); // the cancel goes through...
drop(gate);
assert!(rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(), "...and the step sees it");
worker.join().unwrap();
// once it is off, cancels are refused
let g2 = a.start("export", "Exporting", Cancel::Yes);
g2.handle().set_cancellable(false);
assert!(a.cancel(g2.id()).is_err());
assert_eq!(a.cancel_all(), 0, "{:?}", a.list());
}
#[test]
fn handle_updates_from_another_thread() {
let a = Activity::default();
let g = a.start("previews", "Building previews", Cancel::Yes);
let h = g.handle();
std::thread::spawn(move || h.progress(3, 7)).join().unwrap();
assert_eq!((a.list()[0].done, a.list()[0].total), (3, 7));
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
+65
View File
@@ -0,0 +1,65 @@
//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "activity.list",
"Background Tasks",
[],
None,
"{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet",
always,
|s, _| Ok(json!({"tasks": s.activity.list()}))
),
cmd!(
"activity.cancel",
"Cancel Background Task",
[],
None,
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error",
always,
|s, p| {
const C: &str = "activity.cancel";
if p.get("all").and_then(Value::as_bool) == Some(true) {
return Ok(json!({"cancelled": s.activity.cancel_all()}));
}
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;
s.activity.cancel(id).map_err(|e| bad(C, e))?;
Ok(json!({"cancelled": 1}))
}
),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
use crate::activity::Cancel;
#[test]
fn list_and_cancel_through_execute() {
let mut s = Session::new();
let g = s.activity.start("export", "Exporting", Cancel::Yes);
let r = s.execute("activity.list", &json!({})).unwrap();
assert_eq!(r["tasks"][0]["kind"], "export");
assert_eq!(r["tasks"][0]["cancellable"], true);
let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap();
assert_eq!(r["cancelled"], 1);
assert!(g.is_cancelled());
}
#[test]
fn cancel_all_and_errors() {
let mut s = Session::new();
let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No));
assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1);
assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err());
assert!(s.execute("activity.cancel", &json!({})).is_err());
}
}
+149 -3
View File
@@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
v.sort();
v
};
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
// looking at a folder never brings its deleted photos back (importing the files does)
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
let report = import_with(s, &files, &opts)?;
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
@@ -105,7 +107,7 @@ impl Session {
let smart = self.media.smart_dir.clone();
let plan = self
.catalog
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()));
if dry_run {
return plan;
}
@@ -195,6 +197,24 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"folder.create",
"New Folder on Disk",
[],
None,
"{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
always,
create_folder
),
cmd!(
"photo.moveToFolder",
"Move to Folder",
[],
None,
"{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}",
always,
move_to_folder
),
cmd!(
"library.browse",
"Browse Folder",
@@ -249,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
}
/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the
/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites.
pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
match (from.is_empty(), to.is_empty()) {
(false, false) => rename_folder_on_disk(from, to),
(false, true) => remove_empty_folder(from),
(true, false) => {
if Path::new(to).exists() {
return Err(format!("{to} already exists"));
}
std::fs::create_dir(to).map_err(|e| format!("{to}: {e}"))
}
(true, true) => Ok(()),
}
}
/// Remove an empty folder; one with anything in it (hidden files too) is refused.
fn remove_empty_folder(path: &str) -> std::result::Result<(), String> {
let p = Path::new(path);
if !p.is_dir() {
return Err(format!("{path} is not a folder"));
}
let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?;
if entries.next().is_some() {
return Err(format!("{path} is not empty"));
}
std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}"))
}
/// A folder path parameter without trailing separators (`None` when missing or blank).
fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
}
/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "folder.create";
let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
.ok_or_else(|| bad(C, "give a folder name (no slashes)"))?;
if !Path::new(parent).is_dir() {
return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
}
let path = Path::new(parent).join(name).to_string_lossy().to_string();
if Path::new(&path).exists() {
return Err(bad(C, format!("{path} already exists: choose another name")));
}
folder_on_disk("", &path).map_err(|e| bad(C, e))?;
let undo = crate::FolderMove { from: path.clone(), to: String::new() };
if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
let _ = remove_empty_folder(&path);
return Err(e);
}
Ok(json!({"path": path}))
}
/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink
/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name
/// already taken in `folder` refuses the whole move.
fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.moveToFolder";
let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
let dir = Path::new(folder);
if !dir.is_dir() {
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
}
let ids = s.targets(p);
if ids.is_empty() {
return Err(bad(C, "no photos to move: select some or give ids"));
}
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut clashes: Vec<String> = Vec::new();
for id in ids {
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
if ph.local {
return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name)));
}
let lightcraft_catalog::Source::File { path } = &ph.source else {
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
};
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
continue; // a virtual copy of a photo already moving
}
let src = Path::new(path);
if !src.is_file() {
return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)")));
}
let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) };
if src.parent() == Some(dir) {
continue; // already there
}
let to = dir.join(file);
let to_s = to.to_string_lossy().to_string();
if to.exists() || !taken.insert(to_s.to_lowercase()) {
clashes.push(file.to_string_lossy().to_string());
continue;
}
let name = ph.file_name.clone();
plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) });
}
if !clashes.is_empty() {
let s_ = if clashes.len() == 1 { "" } else { "s" };
return Err(bad(
C,
format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")),
));
}
let n = plans.len();
if n == 0 {
return Ok(json!({"moved": 0, "folder": folder}));
}
let plural = |k: usize| if k == 1 { "" } else { "s" };
let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
Ok(json!({"moved": n, "folder": folder}))
}
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
// a library folder chosen at or below it follows too, as a source or as a filter
@@ -285,8 +426,11 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
return Err("the library's own folders can't be moved here".into());
}
// Repairs retain their exact old paths, including those on surviving history entries.
// Complete them before the directory moves; an unavailable repair leaves it in place.
s.repair_sidecars_with(&crate::rename::RealFs).map_err(|e| e.to_string())?;
rename_folder_on_disk(from, to)?;
let ops: Vec<Op> = s
let mut ops: Vec<Op> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
@@ -301,6 +445,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
})
.collect();
let n = ops.len();
// what the library knows about the folder (and those inside it) goes along, in the same step
ops.extend(s.catalog.folder_records_follow(from, to));
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let renamed = src.parent() == dst.parent();
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
+190 -30
View File
@@ -120,33 +120,67 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
/// stack it on top of the original. The host opens the file in the editor.
/// TIFF (or PSD, or 8-bit) next to the original (`<name>-Edit.tif`, never overwriting), add it to
/// the library and stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
},
};
let mut ids = s.targets(p);
ids.dedup();
if ids.is_empty() {
return Err(bad(C, "no active photo"));
}
if ids.len() > MAX_EDIT_COPIES {
return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len())));
}
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
};
let format = match super::str_param(p, "format").unwrap_or("tiff") {
"tiff" | "tif" => crate::export::ExportFormat::Tiff,
"psd" => crate::export::ExportFormat::Psd,
f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))),
};
let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) {
8 => 8,
16 => 16,
d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))),
};
let opts = crate::export::ExportOptions {
format: crate::export::ExportFormat::Tiff,
bit_depth: Some(16),
format,
bit_depth: Some(bit_depth),
color_space: space,
naming: "{name}-Edit".into(),
conflict: crate::export::Conflict::Unique,
..Default::default()
};
let mut copies = Vec::new();
for id in ids {
copies.push(edit_copy(s, p, id, &opts)?);
}
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
s.selection = crate::Selection { active: new.first().copied(), ids: new };
let first = copies.first().cloned().unwrap_or(Value::Null);
Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies}))
}
/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo).
const MAX_EDIT_COPIES: usize = 50;
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
const C: &str = "photo.editExternal";
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
},
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
// durable: the TIFF becomes a library photo the external editor then changes
@@ -155,13 +189,13 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
};
let to = crate::export::Destination { dir, exact: None };
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let ext = format!(".{}", opts.format.extension());
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
let r = s.execute("library.import", &json!({"paths": [out]}))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
// stack: the edit on top of the original, expanded so both show
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
Ok(json!({"path": out, "id": new, "original": id.0}))
}
@@ -210,6 +244,33 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
ops
}
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
/// it already has them. A catalog from before the lens data was read at import has none, so such a
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
/// its settings.
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
if info.embedded_lens == ph.embedded_lens {
return Vec::new();
}
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
// (a photo with a default-preset look keeps it: that look is its import state)
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
let mut d = (*ph.develop).clone();
d.optics.lens_profile = true;
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
}
ops
}
/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the
/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices
/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730).
pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?;
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@@ -229,21 +290,21 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let Some(op) = content_op(id, ph, info.clone()) else {
if !meta_ops.is_empty() {
ops.extend(meta_ops);
reloaded.push(id);
}
continue;
};
ops.push(op);
ops.extend(meta_ops);
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)) {
ops.extend(content_op(c.id, c, info.clone()));
reloaded.push(c.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);
}
}
reloaded.push(id);
}
for id in &reloaded {
s.media.forget(*id);
@@ -316,7 +377,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Reload from Disk",
["Photo"],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
has_selection,
reload
),
@@ -325,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Edit Copy for External Editor",
[],
None,
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
"{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)",
has_active,
edit_external
),
@@ -359,4 +420,103 @@ mod tests {
ph.captured = Some("2026-10-04T08:16:11".into());
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
}
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() }),
vignette: None,
stages: Default::default(),
}
}
fn old_rw2() -> lightcraft_catalog::Photo {
// as an older build catalogued it: no lens data, correction off
lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(1),
Source::File { path: "/x.rw2".into() },
"x.rw2",
"RW2",
4592,
3448,
"2026-10-06T00:00:00",
)
}
#[test]
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
let ph = old_rw2();
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
let ops = lens_ops(ph.id, &ph, &info);
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
assert!(settings.optics.lens_profile);
// once applied, the photo is still "unedited" and has what import would have given it
let mut c = lightcraft_catalog::Catalog::default();
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
for op in ops {
c.apply(op).unwrap();
}
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
assert_eq!(after.embedded_lens, Some(lens()));
assert!(!after.is_edited());
assert_eq!(*after.develop, after.camera_defaults());
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
}
#[test]
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
let mut ph = old_rw2();
let mut d = (*ph.develop).clone();
d.light.exposure = 0.7;
ph.develop = std::sync::Arc::new(d);
assert!(ph.is_edited());
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
let ops = lens_ops(ph.id, &ph, &info);
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
}
#[test]
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
let mut ph = old_rw2();
ph.embedded_lens = Some(lens());
let mut d = ph.camera_defaults();
d.optics.lens_profile = false;
ph.develop = std::sync::Arc::new(d);
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
assert!(lens_ops(ph.id, &ph, &info).is_empty());
}
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
#[test]
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.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());
return;
}
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let ph = s.catalog.photo(id).unwrap().clone();
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
// back to how 0.4.0 catalogued it
let mut old = (*ph.develop).clone();
old.optics.lens_profile = false;
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
s.media.forget(id);
let before = s.render_now(id, 160, 120).unwrap().image.data;
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]));
let now = s.catalog.photo(id).unwrap().clone();
assert_eq!(now.embedded_lens, ph.embedded_lens);
assert!(now.develop.optics.lens_profile && !now.is_edited());
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
s.execute("edit.undo", &json!({})).unwrap();
let undone = s.catalog.photo(id).unwrap();
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
}
}
+17 -5
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()}))
@@ -368,7 +379,7 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "that model is already installed"));
}
let host = lightcraft_denoise::known::host(&spec.url).to_string();
s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?;
Ok(json!({"started": id, "from": host}))
}
@@ -614,6 +625,7 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
s.denoise_track();
Ok(json!({
"active": s.denoise.active.is_some(),
"running": s.denoise_running_json(),
@@ -652,7 +664,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?, remote?: {address, state: pending | unavailable | used | idle | slower | given up, device?, remoteMs?, localMs?, reason?, failures?, lastError?}}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
]
+244 -36
View File
@@ -1,7 +1,7 @@
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
@@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
if o.swaps_axes() { (h, w) } else { (w, h) }
}
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
/// canvas outside the photo (issue #742).
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
if touched {
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
}
}
const CROP_ANGLE: &str = "crop.angle";
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
v?.as_array()?.iter().map(Value::as_f64).collect()
@@ -74,6 +85,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
fn outdated(s: &Session, id: PhotoId) -> bool {
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
}
fn already_current() -> String {
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
}
/// Update to Current Process applies to the selection (the selected photos, else the active
/// photo): one of them is on an older rendering process. Calls that name their photos are
/// checked by the command instead (`explicit_targets`).
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let sel = &s.selection;
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
}
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
/// names none (the selection applies).
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
if !super::names_photos(p) {
return Ok(None);
}
let given = match p.get("ids").filter(|v| !v.is_null()) {
Some(Value::Array(a)) => a.clone(),
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
None => p.get("id").cloned().into_iter().collect(),
};
if given.is_empty() {
return Err(bad(c, "`ids` names no photos"));
}
let ids = given
.iter()
.map(|v| match v.as_u64().map(PhotoId) {
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
_ => Err(bad(c, format!("no such photo {v}"))),
})
.collect::<Result<Vec<_>>>()?;
Ok(Some(ids))
}
/// The user preset named by `p.id` (built-ins can't be changed).
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
@@ -103,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Develop Values",
[],
None,
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
has_active,
|s, p| {
let mut vals: Vec<(String, f64)> = Vec::new();
@@ -124,10 +178,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
let before = d.crop.geometry;
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
lightcraft_pipeline::local::normalize_wb(d, info);
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
}
for (k, v) in &vals {
controls::set(d, k, *v);
@@ -135,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
d.wb.mode = WbMode::Custom;
}
}
refit_crop_angle(angle, before, d, dims);
};
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
@@ -144,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|(id, d)| {
let mut d = (*d).clone();
let info = s.source_info(id);
apply(&mut d, &info);
apply(&mut d, &info, crop_dims(s, id));
s.develop_op(id, d, &label)
})
.collect();
@@ -153,8 +213,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
let id = active(s, "develop.set")?;
let info = s.source_info(id);
let dims = crop_dims(s, id);
edit(s, "develop.set", &label, |d| {
apply(d, &info);
apply(d, &info, dims);
Ok(())
})
}
@@ -163,9 +224,12 @@ pub fn specs() -> Vec<CommandSpec> {
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
let delta = f64_req(p, "delta", "develop.adjust")?;
let info = s.source_info(active(s, "develop.adjust")?);
let id = active(s, "develop.adjust")?;
let (info, dims) = (s.source_info(id), crop_dims(s, id));
edit(s, "develop.adjust", spec.label, |d| {
let before = d.crop.geometry;
if c == "wb.temp" || c == "wb.tint" {
lightcraft_pipeline::local::normalize_wb(d, &info);
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
@@ -174,6 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
let v = controls::get(d, &c).unwrap_or(spec.default);
controls::set(d, &c, v + delta);
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
Ok(())
})
}),
@@ -196,19 +261,62 @@ pub fn specs() -> Vec<CommandSpec> {
let fresh = match look {
Some(l) => (*l).clone(),
None => {
// the photo's built-in defaults (raws: capture sharpening and colour noise
// reduction, embedded lens corrections), as at import, with the white balance
// of its current as-shot reference
let info = s.source_info(id);
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
..Default::default()
}
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
let mut fresh = DevelopSettings {
wb: lightcraft_develop::WhiteBalance {
mode: WbMode::AsShot,
temp: info.as_shot_temp,
tint: info.as_shot_tint,
..Default::default()
},
..defaults
};
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
fresh
}
};
s.develop_op(id, fresh, "Reset")
// a fresh start renders with the current process (a section or slider reset doesn't change it)
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
})
.collect();
s.commit("Reset", Op::Batch { ops })?;
ok()
}),
CommandSpec {
explicit_targets: true,
..cmd!(
"develop.updateProcess",
"Update to Current Process",
["Photo"],
None,
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
has_outdated_process,
|s, p| {
const C: &str = "develop.updateProcess";
const LABEL: &str = "Update to Current Process";
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),
};
let ops: Vec<Op> = targets
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter(|(_, d)| d.process.is_outdated())
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
.collect();
let n = ops.len();
if n > 0 {
s.commit(LABEL, Op::Batch { ops })?;
}
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
}
)
},
cmd!(
"develop.resetSection",
"Reset Section",
@@ -227,8 +335,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
let dims = crop_dims(s, active(s, "develop.resetControl")?);
edit(s, "develop.resetControl", spec.label, |d| {
let before = d.crop.geometry;
controls::set(d, &c, spec.default);
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
Ok(())
})
}),
@@ -302,7 +413,7 @@ pub fn specs() -> Vec<CommandSpec> {
m => m
.preset()
.map(|(t, ti)| {
// presets are relative to daylight for rendered files
// presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
})
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
@@ -312,6 +423,7 @@ pub fn specs() -> Vec<CommandSpec> {
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
// mode is kept for the UI's WB dropdown.
edit(s, "develop.wb", "White Balance", |d| {
lightcraft_pipeline::local::normalize_wb(d, &info);
d.wb.mode = mode;
d.wb.temp = t.clamp(2000.0, 50000.0);
d.wb.tint = tint.clamp(-150.0, 150.0);
@@ -341,6 +453,7 @@ pub fn specs() -> Vec<CommandSpec> {
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
edit(s, "develop.wbPick", "White Balance", |d| {
lightcraft_pipeline::local::normalize_wb(d, &info);
d.wb.mode = WbMode::Custom;
d.wb.temp = t;
d.wb.tint = tint;
@@ -414,10 +527,12 @@ pub fn specs() -> Vec<CommandSpec> {
}
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
}
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
}
d.crop.geometry = d.crop.geometry.constrained(w, h);
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
d.crop.geometry = match (p.get("rect"), angle) {
// a new angle alone turns the crop as Straighten does (issue #742)
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
};
Ok(())
})
}),
@@ -426,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Drag Crop Handle",
[],
None,
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)",
has_active,
|s, p| {
let id = active(s, "crop.drag")?;
@@ -451,11 +566,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
};
let keep_ratio = bool_or(p, "keepRatio", false);
edit(s, "crop.drag", "Crop", |d| {
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
// orientation is reinterpreted per image, so the frame itself is the truth.
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
// `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock.
let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0);
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
Ok(())
})
@@ -473,11 +590,8 @@ pub fn specs() -> Vec<CommandSpec> {
let (w, h) = crop_dims(s, id);
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
edit(s, "crop.straighten", "Straighten", |d| {
let r = d.crop.geometry.rect_px(w, h);
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
// keep the current crop size if it still fits, else shrink
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
// keep the current crop if it still fits, else the largest of its aspect that does
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
Ok(())
})
}
@@ -572,6 +686,33 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
// ---- HDR editing
cmd!(
"develop.hdr",
"HDR",
[],
None,
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
has_active,
|s, p| {
let id = active(s, "develop.hdr")?;
let cur = s.develop_of(id).unwrap_or_default().hdr;
let enabled = bool_or(p, "enabled", !cur.enabled);
let max_ev = match p.get("maxEv") {
None => None,
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
};
let label = if enabled { "HDR On" } else { "HDR Off" };
edit(s, "develop.hdr", label, |d| {
d.hdr.enabled = enabled;
if let Some(m) = max_ev {
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
}
Ok(())
})?;
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
}
),
// ---- geometry: Upright
cmd!(
"geometry.upright",
@@ -745,13 +886,19 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
if ctl == "wb.temp" || ctl == "wb.tint" {
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
let info = s.source_info(id);
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
if nd.wb.mode == WbMode::AsShot {
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
}
}
let before = nd.crop.geometry;
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
if ctl == "wb.temp" || ctl == "wb.tint" {
nd.wb.mode = WbMode::Custom;
}
@@ -932,15 +1079,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| {
@@ -957,3 +1136,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())
}
}
+104 -2
View File
@@ -78,8 +78,87 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(list(s))
}
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
#[cfg(not(target_arch = "wasm32"))]
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
match &s.apple_photos {
Some(_) => Ok(()),
None => Err(crate::apple_photos::unavailable().into()),
}
}
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
/// worker thread (the default in the desktop app, which answers control requests on its UI
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
#[cfg(not(target_arch = "wasm32"))]
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.addToPhotos";
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
}
let paths: Vec<String> = match p.get("paths") {
Some(Value::Array(a)) => a
.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<_>>()
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
};
let album = match p.get("album") {
None | Some(Value::Null) => "",
Some(Value::String(a)) => a.as_str(),
Some(_) => return Err(bad(ID, "`album` must be a string")),
};
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
// in the middle of the import
let wait = match p.get("wait") {
None | Some(Value::Null) => !s.apple_photos_background,
// the desktop app answers on its window's thread, which never waits for Photos
Some(Value::Bool(true)) if s.apple_photos_background => {
return Err(bad(
ID,
"`wait: true` isn't available in the desktop app: the import runs in the background; its outcome comes from export.photosImports {job}",
));
}
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
};
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait, Some(&s.activity)).map_err(|e| bad(ID, e))?;
match job.outcome() {
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
_ => Ok(job.json()),
}
}
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
#[cfg(not(target_arch = "wasm32"))]
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.photosImports";
match p.get("job") {
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
Some(v) => {
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
}
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
#[allow(unused_mut)]
let mut v = vec![
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
always, |s, p| {
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
}
),
cmd!(
"export.presets",
"Export Presets",
@@ -112,5 +191,28 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": path}))
}
),
]
];
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
// replay must not import again.
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
query "export.addToPhotos",
"Add to Apple Photos",
[],
None,
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app, where wait: true is refused (its control requests are answered on the UI thread, which never waits for Photos). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
photos_enabled,
add_to_photos
));
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
query "export.photosImports",
"Apple Photos Imports",
[],
None,
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
always,
photos_imports
));
v
}
+18 -2
View File
@@ -356,7 +356,13 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "that model is already installed"));
}
let host = spec.host().to_string();
s.face_downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
let name = known::all()
.into_iter()
.find(|m| m.id == spec.id)
.map(|m| m.name)
.or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == spec.id).map(|e| e.manifest.name.clone()))
.unwrap_or_else(|| spec.id.clone());
s.face_downloads.start(spec, &dir, &s.activity, &name).map_err(|e| bad(C, e))?;
Ok(json!({"started": id, "from": host}))
}
@@ -458,6 +464,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
if st.embedder.as_deref() == Some(id) {
// another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen
st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id);
// on with nothing to run is not a state (issue #678)
st.enabled &= st.embedder.is_some();
write_settings(&dir, &st)?;
}
Ok(json!({"removed": id, "embedder": st.embedder}))
@@ -485,7 +493,15 @@ fn enable(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.enable";
let dir = models_dir(s, C)?;
let mut st = read_settings(&dir);
st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
let on = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
// on means something only with a model to run (issue #678): a toggle that is on with nothing
// chosen would sit there doing nothing, with no word about why
let usable =
st.embedder.as_deref().is_some_and(|id| installed_models(&dir).iter().any(|i| i.manifest.id == id && i.manifest.role == Role::Embedder));
if on && !usable {
return Err(bad(C, "choose a recognition model first (Settings ▸ Faces ▸ Use)"));
}
st.enabled = on;
write_settings(&dir, &st)?;
// the background scan notices at once, not within the second it trusts its last look
#[cfg(not(target_arch = "wasm32"))]
+22 -1
View File
@@ -515,6 +515,21 @@ mod imp {
}))
}
/// The scan's row in the activity stack: there while `pending` photos are left, its whole the most that were left at
/// once since the scan last finished (which it returns; 0 when none are left). It can't be stopped from there: the
/// scan is switched off in Settings.
fn track(s: &mut Session, pending: u64) -> u64 {
if pending == 0 {
s.faces.task = None;
s.faces.peak = 0;
return 0;
}
s.faces.peak = s.faces.peak.max(pending);
let task = s.faces.task.get_or_insert_with(|| s.activity.start("faces", "Finding faces", crate::activity::Cancel::No));
task.progress(s.faces.peak.saturating_sub(pending), s.faces.peak);
s.faces.peak
}
/// `faces.pump`: cheap to call every frame. Takes in what the background worker has finished and, when it is idle,
/// hands it the next photo with faces to embed. Does nothing (and says so) until recognition is switched on in
/// Settings with a model chosen.
@@ -528,14 +543,17 @@ mod imp {
finish_downloads(s);
let enabled = s.face_models_dir.clone().is_some_and(|d| enabled_cached(s, &d));
if !enabled {
track(s, 0);
return Ok(json!({"active": false}));
}
if s.faces.retry_at.is_some_and(|t| Instant::now() < t) {
track(s, 0);
return Ok(json!({"active": false}));
}
let Ok(embedder) = current_with(s, C, false) else {
// the chosen model is gone or broken: look again in a few seconds, not every frame
s.faces.retry_at = Some(Instant::now() + Duration::from_secs(3));
track(s, 0);
return Ok(json!({"active": false}));
};
s.faces.retry_at = None;
@@ -573,10 +591,13 @@ mod imp {
break;
}
}
let pending = s.faces.queue.len() + s.faces.in_flight.len();
let peak = track(s, pending as u64);
Ok(json!({
"active": true,
"inFlight": s.faces.in_flight.len(),
"pendingPhotos": s.faces.queue.len() + s.faces.in_flight.len(),
"pendingPhotos": pending,
"peak": peak,
"indexedFaces": s.faces.index.len(),
"searchedPhotos": s.faces.scanned.len(),
"workers": target,
+472
View File
@@ -0,0 +1,472 @@
//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the
//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user
//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder;
//! after that every lookup is local and no position ever leaves the machine.
//!
//! Sources: GeoNames dump readme (prose); own design.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::SystemTime;
use serde_json::{Value, json};
use lightcraft_catalog::{Op, PhotoId};
use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon};
use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param};
use crate::activity::Cancel;
use crate::{Result, Session};
/// Where the place names live: `dataset` (a folder) or `<settings>/geonames`.
fn dataset_dir(p: &Value) -> Option<PathBuf> {
str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames")))
}
/// How a place-names download ended.
#[derive(Clone, Debug, PartialEq)]
pub enum DownloadOutcome {
/// Done: this many places.
Done(usize),
/// Failed, in words the user can act on.
Failed(String),
Cancelled,
}
#[derive(Default)]
struct DownloadShared {
running: bool,
last: Option<DownloadOutcome>,
/// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]).
unreported: Option<DownloadOutcome>,
}
/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state.
#[derive(Default)]
pub struct PlaceNames {
shared: Arc<Mutex<DownloadShared>>,
/// (cities file, its size, its modification time, the parsed names).
cache: Option<(PathBuf, u64, Option<SystemTime>, Arc<GeoNames>)>,
}
impl PlaceNames {
fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> {
self.shared.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A download that ended since the last call (the UI shows it once: an error stays readable as a toast).
pub fn take_outcome(&self) -> Option<DownloadOutcome> {
self.lock().unreported.take()
}
pub fn downloading(&self) -> bool {
self.lock().running
}
/// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes.
fn load(&mut self, dir: &Path) -> std::result::Result<Arc<GeoNames>, lightcraft_geocode::GeocodeError> {
let file = dir.join(GEONAMES_FILES[0]);
let meta = std::fs::metadata(&file).ok();
let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok()));
if let Some((f, l, t, g)) = &self.cache
&& meta.is_some()
&& *f == file
&& *l == len
&& *t == mtime
{
return Ok(g.clone());
}
let g = Arc::new(GeoNames::load(dir)?);
self.cache = Some((file, len, mtime, g.clone()));
Ok(g)
}
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
let dir = dataset_dir(p);
let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len());
let sh = s.place_names.lock();
let (last, error) = match &sh.last {
None => (Value::Null, Value::Null),
Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null),
Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)),
Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null),
};
Ok(json!({
"downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error,
"attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE,
}))
}
/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`.
fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) {
for (field, value) in [
(&mut meta.location, &place.sublocation),
(&mut meta.city, &place.city),
(&mut meta.state, &place.state),
(&mut meta.country, &place.country),
] {
if !value.is_empty() && (overwrite || field.trim().is_empty()) {
*field = value.clone();
}
}
}
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.reverseGeocode";
let overwrite = bool_or(p, "overwrite", false);
let ids = s.targets(p);
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
.filter_map(|id| {
let (la, lo) = s.catalog.photo(*id)?.meta.gps?;
let at = LatLon::new(la, lo);
at.is_valid().then_some((*id, at))
})
.collect();
if todo.is_empty() {
return Err(bad(C, "none of these photos has a GPS position"));
}
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?;
// a large selection shows in the activity stack and can be stopped
let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes));
let mut ops = Vec::new();
let mut found = Vec::new();
for (n, (id, at)) in todo.iter().enumerate() {
if let Some(t) = &task {
if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {
return Err(bad(C, "cancelled"));
}
t.progress(n as u64, todo.len() as u64);
}
let Some(place) = names.reverse(*at) else { continue };
let Some(ph) = s.catalog.photo(*id) else { continue };
let mut meta = ph.meta.clone();
fill(&mut meta, &place, overwrite);
if meta != ph.meta {
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
}
found.push(json!({"id": id.0, "place": place}));
}
drop(task);
let changed = ops.len();
if !bool_or(p, "dryRun", false) && changed > 0 {
s.commit("Reverse Geocode", Op::Batch { ops })?;
}
Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION}))
}
#[cfg(not(target_arch = "wasm32"))]
mod download {
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
use super::*;
use crate::activity::Unit;
const MAX_ZIP: u64 = 64 * 1024 * 1024;
const MAX_TEXT: usize = 256 * 1024 * 1024;
fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> {
let _ = std::fs::remove_file(dir.join(name));
let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP };
lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| {
task.progress(pr.done, pr.total.max(pr.done))
})
.map_err(|e| e.to_string())
}
/// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete.
pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<usize, String> {
let staging = dir.join(".download");
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?;
get(GEONAMES_ZIP, base, &staging, cancel, task)?;
let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?;
let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?;
let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len();
if places == 0 {
return Err("the downloaded place list is empty".into());
}
let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP));
let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}"));
for name in &GEONAMES_FILES[1..] {
// optional: without them states stay blank and countries are ISO codes
match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) {
Ok(b) => write(name, &b)?,
Err(e) => log::warn!("place names: {name}: {e}"),
}
let _ = std::fs::remove_file(staging.join(name));
}
write(GEONAMES_FILES[0], &cities)?;
let _ = std::fs::remove_dir(&staging);
Ok(places)
}
/// A download base: https, or plain http to this machine's loopback address only (a test
/// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/`
/// is refused.
pub(super) fn base_allowed(base: &str) -> bool {
base.starts_with("https://")
|| base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/'))
}
/// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable).
pub(super) fn start(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.downloadPlaceNames";
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string();
if !base_allowed(&base) {
return Err(bad(C, "`base` must be an https:// address"));
}
let shared = s.place_names.shared.clone();
{
let mut sh = s.place_names.lock();
if sh.running {
return Err(bad(C, "the place names are already downloading"));
}
sh.running = true;
}
let cancel = Arc::new(AtomicBool::new(false));
let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone()));
task.set_unit(Unit::Bytes);
task.detail("GeoNames");
let handle = task.handle();
let thread_dir = dir.clone();
let thread_shared = shared.clone();
let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {
// the activity row goes when the thread ends, however it ends
let _task = task;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));
let outcome = match outcome {
Ok(Ok(n)) => DownloadOutcome::Done(n),
_ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,
Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")),
Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()),
};
if let DownloadOutcome::Failed(e) = &outcome {
log::warn!("{e}");
}
let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);
sh.running = false;
sh.last = Some(outcome.clone());
sh.unreported = Some(outcome);
});
if let Err(e) = spawned {
shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;
return Err(bad(C, format!("could not start the download: {e}")));
}
Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION}))
}
}
pub fn specs() -> Vec<CommandSpec> {
#[allow(unused_mut)]
let mut v = vec![
cmd!(
"metadata.reverseGeocode",
"Fill Location from GPS",
["Photo"],
None,
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
has_selection,
reverse
),
cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status),
];
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
"metadata.downloadPlaceNames",
"Download Place Names (GeoNames)",
["Photo"],
None,
"{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent",
always,
download::start
));
v
}
#[cfg(test)]
mod tests {
use super::*;
const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\
2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n";
fn dataset(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap();
std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap();
std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap();
dir
}
fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) {
let mut meta = s.catalog.photo(id).unwrap().meta.clone();
meta.gps = gps;
meta.city = city.into();
meta.state.clear();
meta.country.clear();
meta.location.clear();
s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();
}
#[test]
fn fills_empty_fields_keeps_typed_ones_and_undoes() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
let dir = dataset("fill");
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster");
set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), "");
set_gps(&mut s, ids[3], Some((0.0, 0.0)), "");
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap();
assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}");
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France"));
let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone();
assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom"));
assert!(s.activity.list().is_empty(), "the activity row ends with the command");
// one undo step
s.undo_step().unwrap();
assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty());
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster");
// overwrite replaces typed text
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap();
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explains_missing_data_and_gps() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
set_gps(&mut s, ids[0], None, "");
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err();
assert!(e.to_string().contains("GPS"), "{e}");
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id()));
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err();
assert!(e.to_string().contains("download"), "{e}");
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap();
assert_eq!(st["downloaded"], false);
}
#[test]
fn the_gazetteer_is_cached_until_its_file_changes() {
let dir = dataset("cache");
let mut names = PlaceNames::default();
let a = names.load(&dir).unwrap();
assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read");
// a different size invalidates it (a new download)
std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap();
let b = names.load(&dir).unwrap();
assert!(!Arc::ptr_eq(&a, &b));
assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome");
std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap();
assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache");
let _ = std::fs::remove_dir_all(&dir);
}
/// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip
/// that isn't one), `short` (the body ends before its Content-Length), anything else 404.
#[cfg(not(target_arch = "wasm32"))]
fn serve() -> String {
use std::io::{BufRead, BufReader, Write};
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes());
std::thread::spawn(move || {
for conn in l.incoming() {
let Ok(mut c) = conn else { continue };
let mut r = BufReader::new(c.try_clone().unwrap());
let mut line = String::new();
let _ = r.read_line(&mut line);
loop {
let mut h = String::new();
if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() {
break;
}
}
let path = line.split(' ').nth(1).unwrap_or("").to_string();
let (status, body, len): (&str, Vec<u8>, Option<usize>) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) {
("good", true) => ("200 OK", zip.clone(), None),
("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None),
("garbage", true) => ("200 OK", b"<html>not a zip</html>".to_vec(), None),
("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())),
_ => ("404 Not Found", b"nope".to_vec(), None),
};
let n = len.unwrap_or(body.len());
let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes());
let _ = c.write_all(&body);
}
});
format!("http://127.0.0.1:{port}")
}
#[cfg(not(target_arch = "wasm32"))]
fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome {
s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap();
assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some());
let started = std::time::Instant::now();
loop {
if let Some(o) = s.place_names.take_outcome() {
// the activity row ends with the thread
while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(s.activity.list().is_empty());
return o;
}
assert!(started.elapsed().as_secs() < 60, "the download never ended");
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn download_end_to_end_and_its_failures() {
let mut s = Session::with_demo();
let ids = s.visible_cloned();
let base = serve();
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err());
for bad in ["missing", "garbage", "short"] {
let o = download(&mut s, &format!("{base}/{bad}/"), &dir);
let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") };
assert!(e.contains("try again"), "{bad}: {e}");
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}");
assert_eq!(st["lastError"].as_str(), Some(e.as_str()));
assert!(s.place_names.take_outcome().is_none(), "reported once");
}
assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2));
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2)));
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
let m = s.catalog.photo(ids[0]).unwrap().meta.clone();
// admin1 was a 404 (optional): the state stays blank, the rest is filled
assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn download_base_is_https_or_loopback_only() {
assert!(download::base_allowed("https://download.geonames.org/export/dump/"));
assert!(download::base_allowed("http://127.0.0.1:8080"));
assert!(download::base_allowed("http://127.0.0.1/dump"));
assert!(!download::base_allowed("http://127.0.0.1.example.com/"));
assert!(!download::base_allowed("http://127.0.0.1@example.com/"));
assert!(!download::base_allowed("http://download.geonames.org/"));
}
}
+319 -31
View File
@@ -1,10 +1,10 @@
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
use lightcraft_catalog::keywords::{clean, is_under};
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
use crate::{Result, Session};
fn strs(p: &Value, key: &str) -> Vec<String> {
@@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
}
}
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
/// keyword and is cleared when its keyword is deleted.
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
let n = match &op {
lightcraft_catalog::Op::Batch { ops } => ops.len(),
_ => 1,
use lightcraft_catalog::Op;
let (photos, listed) = match &op {
Op::Batch { ops } => {
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
}
Op::SetMeta { .. } => (1, 0),
_ => (0, 1),
};
if n > 0 {
if photos + listed > 0 {
s.commit(label, op)?;
}
if let Some(k) = s.filter.keyword.clone() {
s.filter.keyword = follow(&k);
}
Ok(json!({"changed": n}))
Ok(json!({"changed": photos, "listed": listed}))
}
/// A keyword's attributes from command params over `base`: those not given keep their value.
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
KeywordInfo {
synonyms: if p.get("synonyms").is_some() {
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
} else {
base.synonyms
},
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
export_containing: bool_or(p, "exportContaining", base.export_containing),
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
person: bool_or(p, "person", base.person),
new_keywords_inside: base.new_keywords_inside,
}
}
/// The keyword a command names (`keyword`), as the library writes it.
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
}
/// A catalog refusal as a command error; a taken name says how to merge.
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
match e {
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
e => bad(command, e.to_string()),
}
}
/// A named set of up to nine keywords.
@@ -38,6 +72,12 @@ pub struct KeywordSet {
pub keywords: Vec<String>,
}
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
/// keywords are.
fn same_set(a: &str, b: &str) -> bool {
lightcraft_catalog::keywords::same(a.trim(), b.trim())
}
/// The set name meaning "the nine most recently added keywords".
pub const RECENT: &str = "Recent Keywords";
@@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
if k.is_empty() {
continue;
}
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
s.recent_keywords.retain(|x| !same_set(x, &k));
s.recent_keywords.insert(0, k);
}
s.recent_keywords.truncate(9);
let _ = s.save_prefs();
}
/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB).
const MAX_LIST_BYTES: u64 = 16 << 20;
/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here).
const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>'];
/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark).
fn read_list(path: &str) -> Result<String> {
use std::io::Read;
let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
let mut bytes = Vec::new();
file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
if bytes.len() as u64 > MAX_LIST_BYTES {
return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
}
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
}
String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text")))
}
/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others
/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty),
/// nine at most, no empty ones at the end.
pub fn slots(typed: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for k in typed.iter().take(9) {
let k = clean(k);
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
out.push(if twice { String::new() } else { k });
}
while out.last().is_some_and(String::is_empty) {
out.pop();
}
out
}
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
pub fn current_keywords(s: &Session) -> Vec<String> {
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
v.truncate(9);
v
@@ -75,13 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
None
} else {
Some(
s.keyword_sets
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.find(|x| same_set(&x.name, name))
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
.name
.clone(),
@@ -95,24 +173,53 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Keyword Set",
[],
None,
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
always,
|s, p| {
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
.to_string();
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
// the set it renames, if any (an empty `replace` is none)
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
Some(old) => Some(
s.keyword_sets
.iter()
.position(|x| same_name(x, old))
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
),
None => None,
};
// keywords not given: the renamed set's own, else the current nine
let keywords = if p.get("keywords").is_some() {
slots(&strs(p, "keywords"))
} else {
current_keywords(s)
match renames.and_then(|at| s.keyword_sets.get(at)) {
Some(x) => x.keywords.clone(),
None => current_keywords(s),
}
};
keywords.truncate(9);
let set = KeywordSet { name: name.clone(), keywords };
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
match renames {
// rename: in its place, onto a name no other set has
Some(at) => {
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
}
if let Some(x) = s.keyword_sets.get_mut(at) {
*x = set;
}
}
// a new set (`new`) never takes another set's name
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
}
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
},
}
s.keyword_set = Some(name);
s.save_prefs()?;
@@ -122,11 +229,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
let before = s.keyword_sets.len();
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
s.keyword_sets.retain(|x| !same_set(&x.name, name));
if s.keyword_sets.len() == before {
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
}
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
s.keyword_set = None;
}
s.save_prefs()?;
@@ -145,12 +252,17 @@ pub fn specs() -> Vec<CommandSpec> {
.and_then(Value::as_u64)
.filter(|i| (1..=9).contains(i))
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
// (an empty slot does nothing)
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 all = !ids.is_empty()
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
&& ids.iter().all(|id| {
s.catalog
.photo(*id)
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
});
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
let key = if all { "removeKeywords" } else { "addKeywords" };
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
@@ -185,6 +297,184 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!(s.catalog.keyword_suggestions(&current, str_param(p, "prefix").unwrap_or(""), n)))
}
),
cmd!(
query "keyword.info",
"Keyword Info",
[],
None,
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
always,
|s, p| {
let path = named_keyword(s, p, "keyword.info")?;
let info = s.catalog.keyword_info(&path).cloned();
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
if let Some(o) = v.as_object_mut() {
o.insert("path".into(), json!(path));
o.insert("listed".into(), json!(info.is_some()));
o.insert("count".into(), json!(count));
o.entry("synonyms").or_insert(json!([]));
}
Ok(v)
}
),
cmd!(
"keyword.create",
"Create Keyword",
[],
None,
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
always,
|s, p| {
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
let parent = match p.get("parent") {
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
Some(_) => None,
None => s.catalog.default_keyword_parent(),
};
let path = clean(&match parent {
Some(parent) => format!("{parent}|{name}"),
None => name,
});
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)
.map_err(|e| refused("keyword.create", e, ""))?;
s.commit("Create Keyword", op)?;
if !photos.is_empty() {
note_recent(s, std::slice::from_ref(&path));
}
Ok(json!({"keyword": path}))
}
),
cmd!(
"keyword.edit",
"Edit Keyword",
[],
None,
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
always,
|s, p| {
let from = named_keyword(s, p, "keyword.edit")?;
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
let op =
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
let to = match from.rsplit_once('|') {
Some((parent, _)) => format!("{parent}|{}", name.trim()),
None => name.trim().to_string(),
};
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
}
),
cmd!(
"keyword.move",
"Move Keyword",
[],
None,
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
always,
|s, p| {
let from = named_keyword(s, p, "keyword.move")?;
let parent = match p.get("parent") {
Some(Value::String(k)) => Some(k.clone()),
Some(Value::Null) => None,
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
};
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
Some(parent) => format!("{parent}|{leaf}"),
None => leaf,
};
let op = s
.catalog
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
}
),
cmd!(
"keyword.purgeUnused",
"Purge Unused Keywords",
[],
None,
"{} — takes off the keyword list the keywords no photo has → {purged}",
always,
|s, _| {
let op = s.catalog.purge_unused_keywords_ops();
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
Ok(json!({"purged": r["listed"]}))
}
),
cmd!(
"keyword.setDefaultParent",
"Put New Keywords Inside",
[],
None,
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
always,
|s, p| {
let keyword = match p.get("keyword") {
Some(Value::Null) | None => None,
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
};
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
Ok(json!({"keyword": s.catalog.default_keyword_parent()}))
}
),
cmd!(
"keyword.export",
"Export Keywords",
[],
None,
"{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}",
always,
|s, p| {
let file = s.catalog.keyword_list_text();
// Capture One's importer refuses ; , < > in a keyword's name
let refuses: Vec<&str> = file
.written
.iter()
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
.collect();
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
Some(path) => {
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
r["path"] = json!(path);
}
None => r["text"] = json!(file.text),
}
Ok(r)
}
),
cmd!(
"keyword.import",
"Import Keywords",
[],
None,
"{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}",
always,
|s, p| {
let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) {
(Some(path), _) => read_list(path)?,
(None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => {
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
}
(None, Some(text)) => text.to_string(),
(None, None) => return Err(bad("keyword.import", "give `path` or `text`")),
};
let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?;
let (op, added, updated) = s.catalog.import_keywords_ops(&entries);
commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?;
Ok(json!({"added": added, "updated": updated}))
}
),
cmd!(
"keyword.rename",
"Rename Keyword",
@@ -197,9 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
let (f, t) = (clean(&from), clean(&to));
commit_keywords(s, "Rename Keyword", op, |k| {
Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() })
})
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
}
),
cmd!(
@@ -229,8 +517,8 @@ pub fn specs() -> Vec<CommandSpec> {
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
commit_keywords(s, "Merge Keywords", op, |k| {
Some(match from.iter().find(|f| is_under(k, f)) {
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
Some(match closest(k, &from) {
Some(f) => reparent(k, f, &into),
None => k.to_string(),
})
})
+314 -83
View File
@@ -4,9 +4,23 @@
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
}
/// The auto-import folder's visible files with their sizes (the file-system half of
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
@@ -68,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
}
let on_deleted = match str_param(p, "onDeleted") {
Some(v) => {
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))?
}
None => Default::default(),
};
@@ -135,6 +149,24 @@ fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
}
/// The targets an album can hold and how many were browsed (Local) photos, which album views
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
let before = targets.len();
let kept: Vec<PhotoId> = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect();
let skipped = before - kept.len();
(kept, skipped)
}
/// Album targets, or the error for a selection that was nothing but Local photos.
fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Result<(Vec<PhotoId>, usize)> {
let (kept, skipped) = album_targets(s, targets);
if kept.is_empty() && skipped > 0 {
return Err(bad(command, super::LOCAL_ONLY));
}
Ok((kept, skipped))
}
/// Apply one op per target as a single undo step.
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
let targets = s.targets(p);
@@ -146,6 +178,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
Ok(json!({"changed": n}))
}
/// 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) {
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 {
ops.push(Op::SetRating { id, rating });
}
}
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);
@@ -169,6 +219,12 @@ 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 mut ops = Vec::new();
@@ -330,6 +386,16 @@ pub fn specs() -> Vec<CommandSpec> {
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
}
// checked like a smart album's rules when this call sets them: an unknown field or a value
// that can't match is an error, not an empty grid. Rules that stopped checking since
// (their album was deleted) don't block other filter changes.
if let Some(rules) = f.rule_set.as_mut() {
rules.upgrade();
}
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
}
s.filter = f;
Ok(json!({"count": s.visible().len()}))
}
@@ -339,7 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library Folders",
[],
None,
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
always,
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
),
@@ -387,6 +453,33 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"removed": ids.len()}))
}
),
cmd!(
"folder.label",
"Set Folder Color Label",
[],
None,
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
always,
|s, p| {
const C: &str = "folder.label";
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
"none" => None,
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
};
// taking a label off always works, also once the folder's photos are gone
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
}
if label.is_none() && s.catalog.folder_record(path).is_none() {
return Ok(json!({"path": path, "label": label}));
}
let op = s.catalog.folder_label_op(path, label);
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
Ok(json!({"path": path, "label": label}))
}
),
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
s.filter = Default::default();
Ok(json!({"count": s.visible().len()}))
@@ -554,6 +647,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;
@@ -564,6 +675,12 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, -1, "Decrease Rating")
}),
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, 1, "Increase Rating")
}),
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
@@ -662,18 +779,8 @@ 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 at = before.iter().position(|id| Some(*id) == s.selection.active);
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
select_after_deletion(s, &before);
Ok(v)
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
@@ -699,10 +806,11 @@ 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 op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
s.selection = Selection::default();
select_after_deletion(s, &before);
Ok(json!({"deleted": t.len()}))
}),
// ---- metadata
@@ -766,16 +874,26 @@ pub fn specs() -> Vec<CommandSpec> {
*field = v.to_string();
}
}
if let Some(k) = strs("keywords") {
m.keywords = k;
}
for k in strs("addKeywords").unwrap_or_default() {
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
use lightcraft_catalog::keywords::{clean, same};
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
let k = clean(k);
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
m.keywords.push(k);
}
};
if let Some(k) = strs("keywords") {
m.keywords.clear();
for k in k {
add(&mut m, &k);
}
}
for k in strs("addKeywords").unwrap_or_default() {
add(&mut m, &k);
}
for k in strs("removeKeywords").unwrap_or_default() {
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
let k = clean(&k);
m.keywords.retain(|x| !same(&clean(x), &k));
}
ops.push(Op::SetMeta { id, meta: Box::new(m) });
}
@@ -790,22 +908,31 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
// ---- albums
cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| {
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
if name.is_empty() {
return Err(bad("album.create", "empty name"));
cmd!(
"album.create",
"New Album",
["File"],
None,
"{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
if name.is_empty() {
return Err(bad("album.create", "empty name"));
}
let folder = bool_or(p, "folder", false);
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let (photos, skipped) =
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
s.commit(
if folder { "New Folder" } else { "New Album" },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
)?;
Ok(json!({"id": id.0, "skipped": skipped}))
}
let folder = bool_or(p, "folder", false);
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
s.commit(
if folder { "New Folder" } else { "New Album" },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
)?;
Ok(json!({"id": id.0}))
}),
),
cmd!(
"album.createSmart",
"New Smart Album…",
@@ -819,7 +946,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("album.createSmart", "empty name"));
}
let rules = match p.get("rules") {
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
None => view_rules(s),
};
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
@@ -829,8 +956,8 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
}
),
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
Ok(json!(FIELDS
.iter()
.map(|(id, label, kind)| {
@@ -838,13 +965,15 @@ pub fn specs() -> Vec<CommandSpec> {
Kind::Text => "text",
Kind::Keywords => "keywords",
Kind::Number => "number",
Kind::Album => "album",
Kind::Date => "date",
Kind::Choice(_) => "choice",
Kind::Bool => "bool",
};
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
if let Kind::Choice(c) = kind {
v["choices"] = json!(c);
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
}
v
})
@@ -855,18 +984,34 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Smart Album",
[],
None,
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
always,
|s, p| {
let id = album_param(p, "id", "album.setRules")?;
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
let rules = if bool_or(p, "fromView", false) {
view_rules(s)
// the view is saved as it is, but never as a loop (showing A, which tests this
// album, and saving that view here would make it test itself)
let view = view_rules(s);
let loops: Vec<String> = view
.rule_set
.as_ref()
.map(|r| r.check_for(&s.catalog, Some(id)))
.unwrap_or_default()
.into_iter()
.chain(s.catalog.album_filter_problem(&view, Some(id)))
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
.map(|p| p.to_string())
.collect();
if !loops.is_empty() {
return Err(bad("album.setRules", loops.join("; ")));
}
view
} else {
let replace = bool_or(p, "replace", false);
let folder = cur.library_folder.clone();
let base = if replace { Default::default() } else { cur };
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
// the rules dialog has no folder field: replacing its rules keeps the folder
// unless the call says (`libraryFolder: null`) to drop it
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
@@ -874,7 +1019,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
r
};
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
// a new name goes with the rules: one step, applied (and undone) together
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
match rename {
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
None => s.commit("Edit Smart Album", set)?,
};
Ok(json!({"count": s.catalog.album_count(id)}))
}
),
@@ -884,14 +1035,45 @@ pub fn specs() -> Vec<CommandSpec> {
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
ok()
}),
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
let id = album_param(p, "id", "album.delete")?;
s.commit("Delete Album", Op::RemoveAlbum { id })?;
if s.source == LibrarySource::Album(id) {
s.source = LibrarySource::All;
cmd!(
"album.delete",
"Delete Album",
[],
None,
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
always,
|s, p| {
let id = album_param(p, "id", "album.delete")?;
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
for album in s.catalog.albums() {
if let Some(parent) = album.parent {
children.entry(parent).or_default().push(album.id);
}
}
// Iterative traversal: even a malformed cycle is rejected before any change.
// Reverse parent-first order removes children first; the batch inverse restores
// parents first, including memberships, smart rules, covers and manual ordering.
let mut pending = vec![id];
let mut order = Vec::new();
let mut removed = std::collections::BTreeSet::new();
while let Some(next) = pending.pop() {
if !removed.insert(next) {
return Err(bad("album.delete", "cycle in album folders"));
}
order.push(next);
if let Some(nested) = children.remove(&next) {
pending.extend(nested);
}
}
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
if matches!(s.source, LibrarySource::Album(current) if removed.contains(&current)) {
s.source = LibrarySource::All;
}
ok()
}
ok()
}),
),
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
let id = album_param(p, "id", "album.move")?;
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
@@ -972,34 +1154,44 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"changed": changed}))
}
),
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
}
let mut photos = al.photos.clone();
let before = photos.len();
for t in targets {
if !photos.contains(&t) {
photos.push(t);
cmd!(
"album.addPhotos",
"Add to Album",
["Photo"],
None,
"{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error",
has_library_selection,
|s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
}
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
let mut photos = al.photos.clone();
let before = photos.len();
for t in targets {
if !photos.contains(&t) {
photos.push(t);
}
}
let added = photos.len() - before;
let cover = al.cover.or(photos.first().copied());
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added, "skipped": skipped}))
}
let added = photos.len() - before;
let cover = al.cover.or(photos.first().copied());
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added}))
}),
),
cmd!(
"album.toggleTarget",
"Add to Target Album",
[],
None,
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
has_selection,
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
has_library_selection,
|s, p| {
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
let (targets, skipped) = targets;
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
Some(id) => id,
@@ -1023,7 +1215,7 @@ pub fn specs() -> Vec<CommandSpec> {
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
)?;
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped}))
}
),
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
@@ -1079,7 +1271,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Auto Import Now",
[],
None,
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
always,
|s, p| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
@@ -1115,7 +1307,9 @@ pub fn specs() -> Vec<CommandSpec> {
return Ok(json!({"imported": [], "folder": folder}));
}
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
let mut params = json!({"paths": fresh, "mode": mode});
// a photo deleted from the library doesn't come back on its own because its file
// is still in the watched folder (a copy's source is not a known path)
let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"});
if let Some(a) = s.import_defaults.auto_album.clone() {
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
Some(al) => params["album"] = json!(al.id.0),
@@ -1133,7 +1327,11 @@ pub fn specs() -> Vec<CommandSpec> {
let r = s.execute("library.import", &params)?;
// arriving photos don't take over the selection
s.selection = sel;
Ok(json!({"imported": r["imported"], "folder": folder}))
let mut result = json!({"imported": r["imported"], "folder": folder});
if let Some(warnings) = r.get("warnings") {
result["warnings"] = warnings.clone();
}
Ok(result)
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
@@ -1195,7 +1393,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Photos",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
always,
|s, p| {
let req = import_params(s, p)?;
@@ -1283,12 +1481,39 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// `base` with a partial Filter (JSON) merged on top.
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
/// [`merge_rules`]).
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
let mut v = serde_json::to_value(base).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, patch);
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
return Err(bad(c, problem));
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(rules) = f.rule_set.as_mut() {
rules.upgrade();
}
Ok(f)
}
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
/// problem too).
fn merge_rules(
base: &lightcraft_catalog::Filter,
patch: &Value,
c: &str,
cat: &lightcraft_catalog::Catalog,
owner: Option<AlbumId>,
) -> Result<lightcraft_catalog::Filter> {
let f = merge_filter(base, patch, c)?;
// only rules this change sets are checked: one that stopped checking since (its album was
// deleted) doesn't block editing the album's other settings
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
// and a loop through the album field the change sets
if patch.get("album").is_some_and(|a| !a.is_null()) {
problems.extend(cat.album_filter_problem(&f, owner));
}
if !problems.is_empty() {
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
}
Ok(f)
}
@@ -1296,7 +1521,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
/// one disk but a way to reach several.
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
ids.iter()
.filter_map(|id| s.catalog.photo(*id))
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
@@ -1324,7 +1549,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
}
}
}
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
// the view as it is: a rule that no longer checks (its album was deleted) just matches
// nothing there, and mustn't cost the filter bar's settings
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
}
None => {
let mut f = s.source.to_filter(&s.filter, &s.catalog);
@@ -1392,3 +1619,7 @@ mod gps_tests {
assert_eq!(p("hello"), None);
}
}
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
@@ -0,0 +1,102 @@
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-09",
);
photo.rating = rating;
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 ratings(s: &Session) -> Result<Vec<u8>> {
(1..=4)
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
.collect()
}
#[test]
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
}
#[test]
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
assert!(s.undo.is_empty());
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
let undo = s.undo.len();
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
assert_eq!(s.undo.len(), undo);
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(3)));
s.selection = Selection::default();
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
}
#[test]
fn relative_ratings_and_history_use_auto_write() {
let mut s = session().unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
file_name: format!("{id}.jpg"),
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
})
.unwrap();
}
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
s.execute("photo.increaseRating", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
s.execute("edit.undo", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
s.execute("photo.decreaseRating", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
std::fs::remove_dir_all(dir).unwrap();
}
+27 -11
View File
@@ -34,22 +34,34 @@ pub fn register_all(s: &Session) {
}
}
/// `.cube` files in `paths` (folders searched, zip bundles opened).
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't
/// be read with the reason (`[name, reason]`).
fn cube_files(paths: &[String]) -> (Vec<(String, String, Option<String>)>, Vec<Value>) {
// (name, text, group)
let mut out = Vec::new();
let mut failed = Vec::new();
let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
if let Ok(t) = std::fs::read_to_string(&ep) {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
match std::fs::read_to_string(&ep) {
Ok(t) => {
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
out.push((stem(&ep), t, group));
}
Err(e) => failed.push(json!([stem(&ep), e.to_string()])),
}
}
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
let Ok(b) = std::fs::read(path) else { continue };
let b = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
failed.push(json!([stem(path), e.to_string()]));
continue;
}
};
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
if inner.to_ascii_lowercase().ends_with(".cube") {
let ip = Path::new(&inner);
@@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
));
}
}
} else if let Ok(t) = std::fs::read_to_string(path) {
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
} else {
match std::fs::read_to_string(path) {
Ok(t) => out.push((stem(path), t, None)),
Err(e) => failed.push(json!([stem(path), e.to_string()])),
}
}
}
out
(out, failed)
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
@@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = &dir {
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
}
let (mut imported, mut failed) = (Vec::new(), Vec::new());
for (name, text, group) in cube_files(&paths) {
let (files, mut failed) = cube_files(&paths);
let mut imported = Vec::new();
for (name, text, group) in files {
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
Ok(l) => l,
Err(e) => {
+27 -6
View File
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
// photos without a capture time start from their import time
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
if shift.is_nan() || shift.abs() > MAX_SHIFT {
return Err(bad(c, "the shift is more than 10,000 years"));
}
let mut delta = shift.round() as i64;
let mut each: Option<String> = None;
if let Some(t) = str_param(p, "time") {
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
@@ -197,23 +203,38 @@ pub fn specs() -> Vec<CommandSpec> {
each = Some(t);
} else {
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
let anchor_date = base(s, anchor);
// An impossible calendar date cannot anchor changes to the other photos.
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
.and_then(|_| iso_seconds(&anchor_date))
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
delta += iso_seconds(&t).unwrap_or(from) - from;
}
}
let mut ops = Vec::new();
let mut out = Vec::new();
let mut out_of_range = 0usize;
for id in &targets {
let new = match &each {
Some(t) => shift_iso(t, delta),
None => shift_iso(&base(s, *id), delta),
let from = match &each {
Some(t) => t.clone(),
None => base(s, *id),
};
let Some(new) = shift_iso(&from, delta) else {
// a date that reads but would leave years 0000–9999 is refused below; one
// that doesn't read is skipped, as before
if iso_seconds(&from).is_some() {
out_of_range += 1;
}
continue;
};
let Some(new) = new else { continue };
out.push(json!(new));
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
}
}
if out_of_range > 0 {
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
}
let n = ops.len();
if n > 0 {
s.commit("Edit Capture Time", Op::Batch { ops })?;
+61 -4
View File
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let m = &mut masks[i];
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
@@ -464,7 +464,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
));
}
let started = s.segmenter.start_download().map_err(ai_err)?;
let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?;
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
}
),
@@ -690,12 +690,69 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.findBlemishes",
"Find Blemishes",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
has_active,
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
let rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
let faces =
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
.iter()
.map(|f| lightcraft_pipeline::blemish::FaceBox {
x0: f64::from(f.x0),
y0: f64::from(f.y0),
x1: f64::from(f.x1),
y1: f64::from(f.y1),
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
})
.collect();
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
let mut per_face = std::collections::BTreeMap::new();
found.retain(|b| {
let n = per_face.entry(b.face).or_insert(0usize);
*n += 1;
*n <= max_per_face
});
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for b in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(b.x, b.y)],
size: b.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: base.feather,
opacity: 100.0,
source_offset: None,
};
spot.source_offset = pick_source(s, id, &dd, &spot, None);
dd.spots.push(spot);
}
s.set_develop(id, dd, "Find Blemishes")?;
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
has_active,
|s, p| {
let mode: SpotMode =
@@ -719,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> {
};
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if spot.source_offset.is_none() {
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
// resolve the automatic source now: it gets a pin and stays put at every size
spot.source_offset = pick_source(s, id, &dd, &spot, None);
}
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
}
fn relink_op(id: PhotoId, path: &str) -> Op {
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
}
Loaded 100 of 467 files, more files were not shown because too many files have changed in this diff. Show more