Compare 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 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 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
树洋 陈 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
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
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
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
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
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
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
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
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
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
Twdhsi-666 e40bf5792a Rebase onto current main so the 15 upstream progress keys are kept 2026-10-10 20:00:00 +08:00
Twdhsi-666 3c5e2f3cb2 locales/zh-hans: translate the remaining AI Denoise strings 2026-10-10 19:51:57 +08:00
Twdhsi-666 aa31ecdc56 locales: complete Simplified Chinese (zh-hans) catalog 2026-10-10 19:50:44 +08: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
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
218 changed files with 15647 additions and 1525 deletions

No files matched your search

+18 -2
View File
@@ -1,5 +1,7 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
# searches (a few minutes).
name: Packaging lint
on:
@@ -71,3 +73,17 @@ jobs:
EOF
- name: FreeBSD tarball layout (stub binaries)
run: bash packaging/freebsd/package.sh --dry-run
windows-install-folder:
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Install-folder fallback and precedence (real MSI AppSearch)
shell: pwsh
run: |
./packaging/windows/test-install-folder.ps1 -Arch x64
./packaging/windows/test-install-folder.ps1 -Arch x86
Generated
+41 -33
View File
@@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.1"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
@@ -2693,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"
@@ -3067,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
@@ -3091,7 +3097,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3108,7 +3114,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3124,12 +3130,13 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"avif-serialize",
"bytemuck",
"image",
"image-webp",
"jixel",
"jpeg-decoder",
"jpeg-encoder",
"jxl-oxide",
@@ -3152,7 +3159,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3161,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3178,7 +3185,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3193,7 +3200,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3205,10 +3212,11 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"ab_glyph",
"harfrust",
"jxl-oxide",
"libc",
"lightcraft-catalog",
"lightcraft-codecs",
@@ -3244,7 +3252,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3258,7 +3266,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"log",
"rustls",
@@ -3269,7 +3277,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3277,7 +3285,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"proptest",
"serde",
@@ -3285,7 +3293,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3303,7 +3311,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"heic-rs",
"rayon",
@@ -3311,14 +3319,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3327,7 +3335,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3338,7 +3346,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3354,7 +3362,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3366,7 +3374,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"image",
"lightcraft-color",
@@ -3384,7 +3392,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3396,7 +3404,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"libc",
"lightcraft-color",
@@ -3408,7 +3416,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3430,7 +3438,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"image",
"lightcraft-color",
@@ -3441,7 +3449,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"candle-core",
"candle-nn",
@@ -3454,11 +3462,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.6.0"
version = "0.7.1"
[[package]]
name = "lightcraft-tiff"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"proptest",
"serde",
@@ -3467,7 +3475,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"craft-ui",
"egui",
@@ -3494,7 +3502,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"crc32fast",
"eframe",
@@ -6964,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"brotli",
"flate2",
+2 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -70,6 +70,7 @@ tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
+4
View File
@@ -314,6 +314,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **network libraries:** a preview reads the raw's header — 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).
@@ -408,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
+5 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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
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
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
| 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) |
@@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- 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
@@ -161,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 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 |
+21 -7
View File
@@ -52,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:
@@ -301,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()),
@@ -523,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()),
@@ -698,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>)> =
@@ -712,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(())
}
+22
View File
@@ -476,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]
+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",
}
}
+1
View File
@@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
reveal: None,
log_file: None,
open_with: None,
open_all_with: None,
check_editor: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
+8 -3
View File
@@ -64,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,
};
@@ -188,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)
}
}
+3 -11
View File
@@ -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());
@@ -326,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 {
+125
View File
@@ -0,0 +1,125 @@
//! The ICC profile the desktop assigns to the display (Linux, through colord).
//!
//! GNOME, KDE and most other desktops keep each monitor's profile in colord
//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a
//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile
//! is chosen in Settings ▸ Display, previews are shown through that profile
//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never
//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB.
//!
//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a
//! window is on), so the profile is used only when every display that has one has the same: a
//! mixed set stays sRGB rather than guess.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
/// How often colord is asked again (a profile change in the desktop's settings shows within this).
const POLL: Duration = Duration::from_secs(3);
/// A wait before trying again when colord isn't there (not installed, no system bus).
const RETRY: Duration = Duration::from_secs(30);
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
const MAX_PROFILE: u64 = 16 << 20;
const SERVICE: &str = "org.freedesktop.ColorManager";
const DEVICE: &str = "org.freedesktop.ColorManager.Device";
const PROFILE: &str = "org.freedesktop.ColorManager.Profile";
/// Start the background reader and return the service the UI polls (`Services::screen_profile`).
pub fn service() -> Box<dyn FnMut() -> Option<Vec<u8>>> {
let shared: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
let writer = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || {
loop {
let wait = match connect() {
Some(conn) => {
let icc = read(&conn);
*writer.lock().unwrap_or_else(PoisonError::into_inner) = icc;
POLL
}
None => RETRY,
};
std::thread::sleep(wait);
// the app is gone: nobody reads the profile any more
if Arc::strong_count(&writer) == 1 {
return;
}
}
});
if let Err(e) = spawned {
log::warn!("screen profile: no colord reader: {e}");
}
Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone())
}
fn connect() -> Option<zbus::blocking::Connection> {
zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok()
}
/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays).
fn read(conn: &zbus::blocking::Connection) -> Option<Vec<u8>> {
let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?;
let devices: Vec<zbus::zvariant::OwnedObjectPath> = manager.call("GetDevicesByKind", &("display",)).ok()?;
let mut files = Vec::new();
for device in devices.iter().take(64) {
let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue };
let profiles: Vec<zbus::zvariant::OwnedObjectPath> = proxy.get_property("Profiles").unwrap_or_default();
// colord lists a device's profiles default first
let Some(first) = profiles.first() else { continue };
let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue };
if let Ok(file) = profile.get_property::<String>("Filename") {
files.push(file);
}
}
let file = pick(&files)?;
load(std::path::Path::new(file))
}
/// The profile to use for these displays' profile files: the one they share, `None` with none or
/// with different ones (empty names are ignored).
fn pick(files: &[String]) -> Option<&str> {
let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty());
let first = named.next()?;
named.all(|f| f == first).then_some(first)
}
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
fn load(path: &std::path::Path) -> Option<Vec<u8>> {
use std::io::Read as _;
let file = std::fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shared_profile_is_used_and_a_mixed_set_is_not() {
let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::<Vec<_>>();
assert_eq!(pick(&s(&[])), None);
assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc"));
assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc"));
assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known");
assert_eq!(pick(&s(&[""])), None);
}
#[test]
fn profile_files_are_read_with_a_size_cap() {
let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let small = dir.join("small.icc");
std::fs::write(&small, b"acsp").unwrap();
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
let empty = dir.join("empty.icc");
std::fs::write(&empty, b"").unwrap();
assert_eq!(load(&empty), None);
assert_eq!(load(&dir.join("missing.icc")), None);
let big = dir.join("big.icc");
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
assert_eq!(load(&big), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+95 -1
View File
@@ -55,16 +55,65 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
}
}
/// How long a request waits for its reply.
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
/// A request the app has not picked up while it drew no frame for this long is given up: the
/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no
/// frame callbacks, so the app's loop is paused: issue #718).
const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
/// The reply for a request given up because the app drew no frames.
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT))
});
}
/// Wait for a request's reply. While the app has not picked the request up and `frames` (its
/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late)
/// and the reply says why; a request the app is running gets the whole `timeout`.
fn await_reply(
rrx: &Receiver<Value>,
state: &std::sync::atomic::AtomicU8,
frames: impl Fn() -> u64,
wake: impl Fn(),
no_frames: Duration,
timeout: Duration,
) -> Value {
let start = std::time::Instant::now();
let slice = (no_frames / 10).max(Duration::from_millis(1));
let (mut last, mut since) = (frames(), std::time::Instant::now());
loop {
match rrx.recv_timeout(slice) {
Ok(v) => return v,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
let now = frames();
if now != last {
(last, since) = (now, std::time::Instant::now());
}
let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN;
if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) {
return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR});
}
if start.elapsed() >= timeout {
// not run yet: make sure it never is; already running: it may still finish
ControlRequest::abandon(state);
return json!({"ok": false, "error": "timeout"});
}
wake();
}
}
/// One parsed line.
enum Line {
Request {
@@ -160,6 +209,51 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
/// up with a reason, quickly, and the app then skips it instead of running it late.
#[test]
fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let t0 = std::time::Instant::now();
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10));
assert!(t0.elapsed() < Duration::from_secs(2));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}");
assert!(!req.take(), "the app does not run it once it shows up");
}
/// A request the app is running is not given up because frames stopped (a long command
/// blocks the frame loop), and its reply arrives.
#[test]
fn a_request_being_run_waits_for_its_reply() {
let (req, rrx) = ControlRequest::new("export.run", json!({}));
assert!(req.take());
let reply = req.reply.clone();
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
let _ = reply.send(json!({"ok": true, "result": 1}));
});
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
worker.join().unwrap();
}
/// Frames still coming: the request just waits, then is answered.
#[test]
fn a_drawing_app_is_waited_for() {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let n = std::sync::atomic::AtomicU64::new(0);
let reply = req.reply.clone();
let state = Arc::clone(&req.state);
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
let _ = reply.send(json!({"ok": true, "result": 2}));
});
let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
worker.join().unwrap();
}
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
+56 -2
View File
@@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
c.spawn().map(|_| ()).map_err(|e| e.to_string())
}
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
/// still starting when it is started once per file. The system default opens them one by one
/// (Explorer and `xdg-open` take one file).
pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> {
match all_command(launcher(app)?, paths) {
Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()),
None => paths.iter().try_for_each(|p| open_with(p, app)),
}
}
/// The one command that opens all of `paths` in `launcher` (none for the system default).
fn all_command(launcher: Launcher, paths: &[String]) -> Option<Command> {
match launcher {
Launcher::Default => None,
Launcher::MacApp(app) => {
let mut c = Command::new("open");
c.args(["-a", &app]).args(paths);
Some(c)
}
Launcher::Program(p) => {
let mut c = Command::new(p);
c.args(paths);
Some(c)
}
Launcher::Flatpak => {
let mut c = Command::new("flatpak");
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@");
Some(c)
}
}
}
/// Windows: open `path` in its default application through Explorer, given the path as its one
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
/// syntax (running what follows an `&` as a command).
@@ -172,8 +205,10 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
Some(rest) => rest.split('"').next()?,
None => exec.split_whitespace().next()?,
};
// a bare name (`photocraft %F`) was already looked for on the PATH
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
program.starts_with('/').then(|| PathBuf::from(program))
}
#[cfg(test)]
@@ -191,6 +226,25 @@ mod tests {
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
}
/// Open Original with several photos: one start of the editor with every file, which a
/// single-window editor (DxO PhotoLab) needs to get them all.
#[test]
fn several_files_go_to_one_start_of_the_editor() {
let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()];
let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap();
assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe");
assert_eq!(args(&c), paths);
let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap();
assert_eq!(
(c.get_program().to_string_lossy().into_owned(), args(&c)),
("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()])
);
let c = all_command(Launcher::Flatpak, &paths).unwrap();
assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]);
assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one");
}
#[test]
fn windows_lookup_prefers_the_recorded_install_folder() {
let env = |k: &str| match k {
+174
View File
@@ -0,0 +1,174 @@
//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR
//! white, and with which surface format. Pure decisions, unit-tested here; the window always
//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong.
//!
//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer,
//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol),
//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal
//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the
//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the
//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same
//! decision turns HDR on.
use eframe::wgpu::TextureFormat;
/// Windowing platform, as far as HDR output is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
MacOs,
Windows,
Wayland,
X11,
Web,
Other,
}
impl Platform {
/// The platform this build runs on (`wayland` from the session environment on Linux / BSD).
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::MacOs
} else if cfg!(target_os = "windows") {
Self::Windows
} else if cfg!(target_arch = "wasm32") {
Self::Web
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
Self::Wayland
} else if std::env::var_os("DISPLAY").is_some() {
Self::X11
} else {
Self::Other
}
}
/// Whether the platform has an HDR compositing path wgpu can reach at all.
fn has_hdr_path(self) -> bool {
matches!(self, Self::MacOs | Self::Windows | Self::Wayland)
}
}
/// How HDR values reach the display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrEncoding {
/// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB,
/// Wayland extended linear).
ExtendedLinear,
/// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ).
Pq10,
}
/// The display output decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayOutput {
Sdr { reason: &'static str },
Hdr { format: TextureFormat, encoding: HdrEncoding },
}
impl DisplayOutput {
#[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR
pub fn is_hdr(self) -> bool {
matches!(self, Self::Hdr { .. })
}
}
/// The encoding a surface format carries on `platform`, if it is an HDR one.
fn encoding_of(format: TextureFormat, platform: Platform) -> Option<HdrEncoding> {
match format {
TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear),
// macOS takes HDR through EDR floats only
TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10),
_ => None,
}
}
/// The best HDR surface format among those a surface offers (`available`), for `platform`:
/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only.
/// (For configuring the surface once eframe lets the app pick its format.)
#[cfg_attr(not(test), allow(dead_code))]
pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option<TextureFormat> {
if !platform.has_hdr_path() {
return None;
}
[TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some())
}
/// The decision for the window: `target` is the format the renderer configured (`None`: the glow
/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the
/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS),
/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`).
pub fn choose(target: Option<TextureFormat>, platform: Platform, display_headroom: Option<f32>, disabled: bool) -> DisplayOutput {
if disabled {
return DisplayOutput::Sdr { reason: "HDR display is switched off" };
}
if !platform.has_hdr_path() {
return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" };
}
let Some(format) = target else {
return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" };
};
let Some(encoding) = encoding_of(format, platform) else {
return DisplayOutput::Sdr { reason: "the window surface is 8-bit" };
};
match display_headroom {
Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding },
Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" },
None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" },
}
}
/// The decision for this start, logged once (the window renderer's `target` format).
pub fn report(target: Option<TextureFormat>) -> DisplayOutput {
let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
// no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready
let out = choose(target, Platform::current(), None, disabled);
match out {
DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"),
DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"),
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use TextureFormat as F;
#[test]
fn eight_bit_surfaces_are_sdr() {
for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] {
for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] {
assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}");
}
}
}
#[test]
fn hdr_needs_platform_format_and_headroom() {
let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear };
assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr);
assert_eq!(
choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false),
DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 }
);
// any missing condition falls back to SDR
assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr());
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr());
assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr());
// macOS has no PQ swap chain path
assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr());
}
#[test]
fn preferred_format_picks_float_then_pq() {
let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float];
assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float));
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm));
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None);
assert_eq!(preferred_hdr_format(&all, Platform::X11), None);
assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None);
}
}
+63 -2
View File
@@ -24,9 +24,12 @@
mod alloc_release;
mod appearance;
#[cfg(target_os = "linux")]
mod colord;
mod control_server;
mod dialog_filter;
mod editors;
mod hdr_display;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -64,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);
@@ -87,6 +90,16 @@ impl eframe::App for App {
if !self.0.shutdown(EXIT_WAIT) {
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
}
// macOS (#1331): end the process here, before eframe tears the window down, as Quit from
// the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's
// content view and restores its class on teardown; AppKit's Touch Bar support observes
// that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation
// invalidate]` throws "not registered" and closing the window ends in a crash report.
// Nothing after this is needed: settings and library are saved, background work has
// stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a
// renderer that fails to start still relaunches on the next one (`window_gpu`).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -450,6 +463,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
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://") {
@@ -525,7 +539,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
#[cfg(not(target_os = "macos"))]
// Linux: the profile the desktop assigns in colord, read off the UI thread
#[cfg(target_os = "linux")]
screen_profile: Some(colord::service()),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = file_dialog()
@@ -595,6 +612,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
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)
}
@@ -831,6 +850,8 @@ fn main() -> eframe::Result {
// (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;
@@ -840,6 +861,7 @@ fn main() -> eframe::Result {
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() },
@@ -945,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]
@@ -1195,6 +1227,35 @@ 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)]
+2 -1
View File
@@ -50,6 +50,7 @@
//! | 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
@@ -74,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 = 7;
pub const VERSION: u32 = 8;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+68 -38
View File
@@ -236,63 +236,84 @@ pub struct KeywordNode {
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
use std::collections::{HashMap, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
use std::collections::HashMap;
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent)
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
struct Level {
name: String,
sort: String,
path: String,
count: usize,
parent: Option<String>,
seen: usize,
}
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
let mut levels: Vec<Level> = Vec::new();
let mut by_lower: HashMap<String, usize> = HashMap::new();
// each level's children (by index), and the top level
let mut kids: Vec<Vec<usize>> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
// the keyword list first (no photos counted): a listed keyword is there without photos
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
// each keyword as written → its levels (its own and those above it), worked out once: a
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
this_photo.clear();
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
for k in keywords {
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
let chain = chains.entry(k.as_str()).or_insert_with(|| {
let mut chain: Vec<usize> = Vec::new();
let mut path = String::new();
let mut lower = String::new();
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
let sort = part.to_lowercase();
lower.push_str(&sort);
let i = match by_lower.get(&lower) {
Some(&i) => i,
None => {
let i = levels.len();
match chain.last().and_then(|&p| kids.get_mut(p)) {
Some(k) => k.push(i),
None => roots.push(i),
}
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
kids.push(Vec::new());
by_lower.insert(lower.clone(), i);
i
}
};
chain.push(i);
}
path.push_str(part);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if counts && this_photo.insert(lower.clone()) {
l.count += 1;
chain
});
if counts {
for &i in chain.iter() {
let Some(l) = levels.get_mut(i) else { continue };
// photo 0 is the keyword list, which counts nothing
if l.seen != photo {
l.seen = photo;
l.count += 1;
}
}
parent = Some(lower.clone());
}
}
}
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
list.into_iter()
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
.filter_map(|i| {
let lv = levels.get(i)?;
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
let children = build(below, levels, kids);
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
})
.collect()
}
build(None, &levels, &mut kids)
build(roots, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
@@ -804,6 +825,15 @@ mod tests {
assert_eq!(c.query(&f, &Default::default()).len(), 3);
}
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
/// photo with it counts again.
#[test]
fn tree_counts_a_repeated_keyword_once_per_photo() {
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
let t = c.keyword_tree();
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
}
#[test]
fn rename_delete_merge_are_single_undoable_batches() {
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
+20 -1
View File
@@ -125,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,
},
@@ -545,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) {
+1
View File
@@ -279,6 +279,7 @@ impl Op {
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. }
| Op::SetHistoryCursor { id, .. }
| Op::SetCaptured { id, .. }
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
+3 -2
View File
@@ -135,8 +135,9 @@ impl LibraryLock {
}
/// Another descriptor on the locked file: what a process spawned by another thread holds
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
#[cfg(test)]
/// between fork and exec (a `dup` refers to the same open file description). Tests only (the
/// test that uses it is Unix-only: `flock` semantics).
#[cfg(all(test, unix))]
pub(crate) fn descriptor_copy(&self) -> Option<File> {
self.file.as_ref().and_then(|f| f.try_clone().ok())
}
+20 -5
View File
@@ -247,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,
@@ -318,15 +323,22 @@ pub fn relative_wb_format(format: &str) -> bool {
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor
/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin:
/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays
/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through
/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree.
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
}
impl Photo {
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
pub fn history_index(&self) -> Option<usize> {
let last = self.history.len().checked_sub(1)?;
Some(self.history_cursor.map_or(last, |c| c.min(last)))
}
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
id,
@@ -347,6 +359,7 @@ impl Photo {
edited: None,
versions: Vec::new(),
history: Vec::new(),
history_cursor: None,
deleted: false,
local: false,
duration: None,
@@ -599,6 +612,8 @@ mod edited_tests {
("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),
+13 -2
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};
@@ -359,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();
@@ -369,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() }
});
+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));
+43
View File
@@ -694,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);
}
+2 -1
View File
@@ -211,8 +211,9 @@ fn op_variants_are_versioned() {
Op::SetKeyword { .. } => 5,
Op::SetFolderRecord { .. } => 6,
Op::SetAsShotWb { .. } => 7,
Op::SetHistoryCursor { .. } => 8,
}
}
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
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");
}
+1 -2
View File
@@ -140,8 +140,7 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
std::thread::spawn(move || {
let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) =
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
spawned += 1;
+6 -1
View File
@@ -32,11 +32,14 @@ jxl-oxide = { workspace = true, optional = true }
ravif = { workspace = true, optional = true }
rav1e = { workspace = true, optional = true }
avif-serialize = { workspace = true, optional = true }
# spawns threads of its own (std::thread), which wasm32 has not got
jixel = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
jxl = ["dep:jxl-oxide"]
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
jxl = ["dep:jxl-oxide", "dep:jixel"]
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
@@ -45,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
# JPEG XL tests read encoded samples without our colour conversion
jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+5 -3
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,7 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
encode_webp_lossless(&img, &meta)?;
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
@@ -51,8 +52,8 @@ 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/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
| 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 |
@@ -87,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
+87 -11
View File
@@ -1,6 +1,6 @@
//! 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`). Metadata (ICC/EXIF/XMP) is
//! embedded where the format allows.
//! 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;
@@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 {
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
}
/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HdrTransfer {
#[default]
Pq,
Hlg,
}
/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408).
pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0;
/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100:
/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma
/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75,
/// as BT.2408 asks. Non-finite or negative inputs count as 0.
pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] {
const K: [f32; 3] = [0.2627, 0.6780, 0.0593];
const GAMMA: f32 = 1.2;
let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS));
let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0);
let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 };
fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32)
}
/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]).
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq)
}
/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level
/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display).
/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
@@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += y_nits as f64;
let [r, g, b] = nits.map(pq_encode);
let [r, g, b] = match transfer {
HdrTransfer::Pq => nits.map(pq_encode),
HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]),
};
let y = K[0] * r + K[1] * g + K[2] * b;
let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
@@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
ec.pixel_range = PixelRange::Full;
ec.color_description = Some(ColorDescription {
color_primaries: ColorPrimaries::BT2020,
transfer_characteristics: TransferCharacteristics::SMPTE2084,
transfer_characteristics: match transfer {
HdrTransfer::Pq => TransferCharacteristics::SMPTE2084,
HdrTransfer::Hlg => TransferCharacteristics::HLG,
},
matrix_coefficients: MatrixCoefficients::BT2020NCL,
});
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
ec.content_light =
(transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
ec.still_picture = true;
ec.quantizer = quantizer;
ec.min_quantizer = quantizer.min(255) as u8;
@@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
use avif_serialize::constants as c;
let mut a = avif_serialize::Aviffy::new();
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
.set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
.set_transfer_characteristics(match transfer {
HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084,
HdrTransfer::Hlg => c::TransferCharacteristics::Hlg,
})
.set_color_primaries(c::ColorPrimaries::Bt2020)
.set_full_color_range(true)
.set_content_light_level(cll.0, cll.1);
.set_full_color_range(true);
if transfer == HdrTransfer::Pq {
a.set_content_light_level(cll.0, cll.1);
}
if let Some(exif) = meta.exif {
a.set_exif(exif.to_vec());
}
@@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
}
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
{
let _ = (quality, speed, meta);
let _ = (quality, speed, meta, transfer);
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}
@@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<
.map_err(|e| Error::Encode(e.to_string()))?;
Ok(r.avif_file)
}
#[cfg(test)]
mod hlg_tests {
use super::*;
#[test]
fn hlg_reference_white_and_peak() {
// BT.2408: SDR (HDR reference) white sits at 75 % HLG signal
for v in hlg_signal([1.0; 3]) {
assert!((v - 0.75).abs() < 0.005, "{v}");
}
// the 1000 cd/m² peak (≈ 4.9× SDR white) reaches full signal; above it clips
let peak = HLG_REFERENCE_PEAK_NITS / HDR_REFERENCE_WHITE_NITS;
assert!(hlg_signal([peak; 3]).iter().all(|v| (v - 1.0).abs() < 1e-3));
assert_eq!(hlg_signal([100.0; 3]), [1.0; 3]);
// black, negatives and non-finite values are 0, never NaN
assert_eq!(hlg_signal([0.0, -1.0, f32::NAN]), [0.0; 3]);
assert!(hlg_signal([f32::INFINITY, 0.0, 0.0]).iter().all(|v| v.is_finite()));
// monotonic in brightness
let mut prev = 0.0;
for i in 1..50 {
let v = hlg_signal([i as f32 * 0.1; 3])[1];
assert!(v >= prev);
prev = v;
}
}
#[test]
fn hdr_avif_rejects_short_buffers() {
let m = EncodeMeta::default();
assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err());
assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err());
}
}
+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}")))
}
+22 -7
View File
@@ -9,7 +9,8 @@
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//!
//! No decoder panics on malformed input (property-tested).
@@ -27,6 +28,7 @@ mod jpeg;
pub mod jpeg_par;
#[cfg(feature = "jxl")]
mod jxl;
mod jxl_encode;
mod other;
mod png_codec;
pub mod proof;
@@ -37,9 +39,10 @@ mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression,
encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal,
};
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
@@ -241,12 +244,24 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
}
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
///
/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest
/// of the pipeline (issue #1212).
pub fn to_working(d: &Decoded) -> Rgb32f {
if d.space.is_working() {
return d.image.clone();
let mut img = if d.space.is_working() {
d.image.clone()
} else {
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
};
// white is (1, 1, 1) in the working space too: the conversion is white-preserving
if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) {
for (p, &av) in img.data.iter_mut().zip(&a.data) {
let av = av.clamp(0.0, 1.0);
*p = p.map(|c| c * av + (1.0 - av));
}
}
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
img
}
/// Where a thumbnail came from.
+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> {
+152
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);
@@ -681,3 +699,137 @@ fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
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);
}
+79
View File
@@ -99,8 +99,45 @@ impl ControlSpec {
let s = format!("{:.*}", self.decimals as usize, v);
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
}
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
/// linear.
pub fn position(&self, v: f64) -> f64 {
let v = self.clamp(v);
let p = match self.kelvin_scale() {
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
None => (v - self.min) / (self.max - self.min).max(1e-9),
};
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
}
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
pub fn value_at(&self, p: f64) -> f64 {
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
let v = match self.kelvin_scale() {
Some(_) if p >= 1.0 => self.max,
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
None => self.min + p * (self.max - self.min),
};
self.clamp(v)
}
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
/// `None` for every other control, or a range the centre isn't inside.
fn kelvin_scale(&self) -> Option<(f64, f64)> {
let centre = KELVIN_CENTRE;
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
}
}
/// The Kelvin temperature at the middle of its track, as in Lightroom.
const KELVIN_CENTRE: f64 = 7000.0;
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
const KELVIN_KNEE: f64 = 1000.0;
macro_rules! controls {
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
/// All numeric develop controls, in panel order.
@@ -508,4 +545,46 @@ mod tests {
assert_eq!(e.format(-1.0), "-1.00");
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
}
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
#[test]
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
let temp = find("wb.temp").unwrap();
for (k, lightroom, model) in [
(2000.0, 0.0, 0.0),
(3650.0, 0.167, 0.165),
(5400.0, 0.333, 0.34),
(7000.0, 0.5, 0.5),
(15000.0, 0.79, 0.79),
(20000.0, 0.853, 0.849),
(30000.0, 0.90, 0.92),
(50000.0, 1.0, 1.0),
] {
let p = temp.position(k);
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
}
// every 50 K step survives value → position → value, and the track never runs backwards
let mut last = -1.0;
for k in (2000..=50000).step_by(50).map(f64::from) {
let p = temp.position(k);
assert!(p > last, "{k} K");
last = p;
let back = (temp.value_at(p) / temp.step).round() * temp.step;
assert_eq!(back, k, "{k} K → {p} → {back}");
}
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
let exposure = find("light.exposure").unwrap();
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
}
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
}
}
+2
View File
@@ -51,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
# HDR JPEG XL tests read the file's colour encoding
jxl-oxide = { workspace = true }
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
lightcraft-heif = { workspace = true, features = ["testdata"] }
libc = { workspace = true }
+64 -17
View File
@@ -73,7 +73,7 @@ impl Unit {
pub struct TaskInfo {
pub id: u64,
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
/// `faces`, `denoise`, `findMissing`.
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
@@ -100,6 +100,12 @@ struct Entry {
detail: Mutex<String>,
cancel: Option<Arc<AtomicBool>>,
cancellable: AtomicBool,
/// Held while a cancel is decided ([`Activity::cancel`], [`Activity::cancel_all`]) and while
/// cancelling is switched ([`TaskGuard::set_cancellable`], [`TaskHandle::set_cancellable`]): a
/// step that turns it off and then reads the cancel flag either sees a cancel that was
/// accepted, or makes the cancel fail. Without it a cancel could pass the check, the step
/// start, and the flag be set after the step's last look at it: accepted but ignored.
gate: Mutex<()>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
@@ -111,6 +117,23 @@ fn capped(text: &str) -> String {
}
impl Entry {
fn set_cancellable(&self, yes: bool) {
let _gate = lock(&self.gate);
self.cancellable.store(yes, Ordering::Relaxed);
}
/// Set the cancel flag if the task can be cancelled now (see [`Entry::gate`]).
fn try_cancel(&self) -> bool {
let _gate = lock(&self.gate);
match &self.cancel {
Some(flag) if self.is_cancellable() => {
flag.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
fn is_cancellable(&self) -> bool {
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
}
@@ -165,6 +188,7 @@ impl Activity {
detail: Mutex::new(String::new()),
cancellable: AtomicBool::new(cancel.is_some()),
cancel,
gate: Mutex::new(()),
});
lock(&self.inner.tasks).push(entry.clone());
TaskGuard { activity: self.clone(), entry }
@@ -180,26 +204,13 @@ impl Activity {
pub fn cancel(&self, id: u64) -> Result<(), String> {
let tasks = lock(&self.inner.tasks);
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
match (&entry.cancel, entry.is_cancellable()) {
(Some(flag), true) => {
flag.store(true, Ordering::Relaxed);
Ok(())
}
_ => Err(format!("{} can't be cancelled", entry.label)),
}
if entry.try_cancel() { Ok(()) } else { Err(format!("{} can't be cancelled", entry.label)) }
}
/// Ask every cancellable task to stop; returns how many were asked.
pub fn cancel_all(&self) -> usize {
let tasks = lock(&self.inner.tasks);
let mut n = 0;
for e in tasks.iter().filter(|e| e.is_cancellable()) {
if let Some(flag) = &e.cancel {
flag.store(true, Ordering::Relaxed);
n += 1;
}
}
n
tasks.iter().filter(|e| e.try_cancel()).count()
}
/// Tasks that quitting would cut short: cancellable ones not already stopping.
@@ -235,7 +246,7 @@ impl TaskGuard {
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
pub fn set_cancellable(&self, yes: bool) {
self.entry.cancellable.store(yes, Ordering::Relaxed);
self.entry.set_cancellable(yes);
}
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
@@ -283,6 +294,13 @@ impl TaskHandle {
pub fn is_cancelled(&self) -> bool {
self.entry.is_cancelled()
}
/// [`TaskGuard::set_cancellable`] from the worker, at the moment its step starts (an export
/// handing its files to Apple Photos, which can't be stopped once asked). Read the cancel flag
/// after turning it off: a cancel accepted before is seen then, any later one is refused.
pub fn set_cancellable(&self, yes: bool) {
self.entry.set_cancellable(yes);
}
}
#[cfg(test)]
@@ -380,6 +398,35 @@ mod tests {
assert_eq!(a.list().len(), 1);
}
/// Codex review of #643: a worker that turns cancelling off and then reads the cancel flag
/// (an export handing its files to Apple Photos) must either see a cancel that was accepted or
/// make it fail, never let one be accepted and then ignored. Here the test holds the task's gate
/// as a cancel does while it decides: the worker's switch waits for it, and then sees the cancel.
#[test]
fn a_step_that_turns_cancelling_off_sees_or_refuses_every_cancel() {
let a = Activity::default();
let flag = Arc::new(AtomicBool::new(false));
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
let handle = g.handle();
let gate = lock(&g.entry.gate); // a cancel deciding
let (tx, rx) = std::sync::mpsc::channel();
let (h, f) = (handle.clone(), flag.clone());
let worker = std::thread::spawn(move || {
h.set_cancellable(false);
tx.send(f.load(Ordering::Relaxed)).unwrap();
});
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "the switch waits for the cancel");
flag.store(true, Ordering::Relaxed); // the cancel goes through...
drop(gate);
assert!(rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(), "...and the step sees it");
worker.join().unwrap();
// once it is off, cancels are refused
let g2 = a.start("export", "Exporting", Cancel::Yes);
g2.handle().set_cancellable(false);
assert!(a.cancel(g2.id()).is_err());
assert_eq!(a.cancel_all(), 0, "{:?}", a.list());
}
#[test]
fn handle_updates_from_another_thread() {
let a = Activity::default();
File diff suppressed because it is too large. Load diff
+405 -17
View File
@@ -10,7 +10,11 @@ use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit, resize},
};
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
use lightcraft_raw::{
ColorData, RawFormat, RawImage,
color::CameraTransform,
profile::{HsvTable, ProfileLook, ToneCurve},
};
/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone
/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because
@@ -25,7 +29,8 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
/// states, for every make (#778).
/// 4: a tone curve from black-and-white camera JPEGs (#577).
pub const LOOK_VERSION: u32 = 4;
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
pub const LOOK_VERSION: u32 = 5;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -107,6 +112,69 @@ fn starting_colour_with(
(xy, t, look)
}
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered
/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them).
pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<ColorData> {
if lightcraft_raw::color::has_matrix(&raw.color) {
return None;
}
let (_, t, look) = starting_colour(raw, bytes);
dng_colour(&raw.color, &t, look.as_ref())
}
/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward
/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation
/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
/// `None` when the white balance or the matrix can't give tags.
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
// into the baseline exposure
let white = m.apply([1.0; 3]);
let k = (white[0] + white[1] + white[2]) / 3.0;
if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) {
return None;
}
let m = Mat3(m.0.map(|r| r.map(|v| v / k)));
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
let wb = t.wb.map(f64::from);
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
Some(ColorData {
illuminant: [21, 0],
color_matrix: [Some(camera_to_xyz.inverse()?), None],
forward_matrix: [Some(forward), None],
camera_calibration: [None, None],
analog_balance: None,
as_shot_neutral: Some(neutral),
as_shot_white_xy: None,
baseline_exposure: t.baseline_exposure + k.log2(),
baseline_sharpness: tags.baseline_sharpness,
kelvin_presets: None,
profile: ProfileLook {
hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None],
tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)),
..Default::default()
},
})
}
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
const N: usize = 256;
let samples = (0..=N).map(|i| {
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32);
[x, tone.apply(x).min(1.0)]
});
let flat: Vec<f64> = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect();
ToneCurve::from_tag(&flat)
}
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
@@ -265,12 +333,19 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
})
}
/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and
/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px);
/// 128 MP also admits 100 MP medium-format previews.
const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
/// and per proxy pixel whether any sensor sample it covers is clipped.
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
let edge = (2 * size).max(384) as u32;
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
let decoded = crate::files::decode_raw_preview(
bytes,
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
)?;
let mut reference = decoded.to_working();
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
@@ -569,7 +644,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
if (0.015..=1.0).contains(&y) {
bright.push((input.map(f64::from), output.map(f64::from)));
}
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
if !colour_pair(*input, *output) {
continue;
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
@@ -580,6 +655,77 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
}
/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting.
fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool {
input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
&& output.iter().all(|v| *v > 0.004 && *v < 0.98)
&& (0.015..0.85).contains(&luminance_2020(output))
}
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
/// The original pairs and their split still own the colour fit and its acceptance gates.
/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and
/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise.
fn fit_tone_with_shadows(
fit: ToneFit,
pairs: &[([f64; 3], [f64; 3])],
sensor: &Rgb32f,
reference: &Rgb32f,
edge_limit: Option<f32>,
colour: impl Fn([f64; 3]) -> [f64; 3],
) -> Option<CameraTone> {
let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y));
let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect());
let mut eligible = Vec::new();
let (mut colour_index, mut extra_index) = (0, 0);
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
continue;
}
// About one 8-bit sRGB code above black: don't learn a flat black segment from
// JPEG compression. The sensor proxy averages many samples before this test.
if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5)
|| !output.iter().all(|v| v.is_finite() && *v >= 0.0)
|| !(0.0003..0.85).contains(&luminance_2020(*output))
{
continue;
}
// A colour holdout must never enter tone training just because the wider sample set
// changes its index. Split the extra samples separately, keeping every colour index.
let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index };
let held = *index % 3 == 0;
*index += 1;
let (x, y) = luminances(input.map(f64::from), output.map(f64::from));
if x.is_finite() && x > 0.0 {
eligible.push((x, y, held));
}
}
let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count();
let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect();
// Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank,
// so a single quantized grey also keeps the original curve.
if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION {
return original;
}
let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect();
let Some(candidate) = fit.fit(training) else { return original };
let error = |tone: &CameraTone| -> f64 {
held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum()
};
// A wider histogram must also help independent dark pixels, not just redistribute the
// knots. The usual held-out gates still check the completed look on the colour pairs.
if let Some(old) = original {
let (before, after) = (error(&old), error(&candidate));
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}");
}
if !after.is_finite() || after >= before * 0.99 {
return original;
}
}
Some(candidate)
}
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
let mut gram = [[0.0; 3]; 3];
@@ -705,9 +851,7 @@ fn fit_pairs_on(
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let Some(curve) = tone_fit.fit(tone_pairs) else { continue };
let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue };
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
if let Some(tone) = fit_chroma(&bright, &look) {
look.tone = tone;
@@ -849,9 +993,8 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
})),
None => Mat3::IDENTITY,
};
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None };
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?;
let mut look = CameraLook { matrix, tone, hue_sat: None };
if let Some(tone) = fit_chroma(&bright, &look) {
look.tone = tone;
}
@@ -957,9 +1100,8 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
let p = matrix.apply(x);
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
};
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect();
let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() };
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
@@ -1317,6 +1459,167 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
mod tests {
use super::*;
fn steep_toe_scene() -> (Rgb32f, Rgb32f) {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
// A broad scene with a steep camera toe below the old JPEG floor of 0.015.
// Permute brightness so the held-out pixels span the same range independently.
let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25;
let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025];
*src = rgb.map(|v| v * y / luminance_2020(rgb));
let display = 3.0 * y * (y / (y + 0.02)).powi(2);
*dst = src.map(|v| v * display / y);
}
(sensor, reference)
}
#[test]
fn camera_tone_follows_the_jpegs_deep_shadows() {
let (sensor, reference) = steep_toe_scene();
let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap();
let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
assert!(dark.len() > 256);
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
assert!(error < 0.001, "deep-shadow luminance error {error}");
assert_eq!(look.matrix, Mat3::IDENTITY);
}
#[test]
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
let (sensor, reference) = steep_toe_scene();
let fit = |reference: &Rgb32f| {
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap();
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
};
let expected = fit(&reference);
let mut changed = reference.clone();
let mut index = 0;
for (x, y) in sensor.data.iter().zip(&mut changed.data) {
if colour_pair(*x, *y) {
if index % 3 == 0 {
*y = y.map(|v| v * 1.1);
assert!(colour_pair(*x, *y));
}
index += 1;
}
}
assert_eq!(fit(&changed), expected);
}
#[test]
fn camera_toe_is_robust_to_shadow_noise() {
let (sensor, clean) = steep_toe_scene();
let mut noisy = clean.clone();
for (i, p) in noisy.data.iter_mut().enumerate() {
if luminance_2020(*p) < 0.015 {
// Quantized JPEG levels with bounded noise and occasional compression outliers.
let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004;
*p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003);
if i % 23 == 0 {
*p = [0.014; 3];
}
}
}
let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap();
let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
assert!(error < 0.001, "noisy shadow luminance error {error}");
assert_eq!(look.tone.apply(0.0), 0.0);
assert_eq!(look.matrix, Mat3::IDENTITY);
for i in 1..1000 {
assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0));
}
}
#[test]
fn camera_toe_requires_lower_independent_dark_error() {
for holdout_matches_original in [true, false] {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 };
*src = [y; 3];
// Midtones teach the original slope of 3. Dark training teaches 0.3,
// but the independent dark holdout can agree with either slope.
let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 };
*dst = [y * slope; 3];
assert_eq!(colour_pair(*src, *dst), i >= 600);
}
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap();
let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap();
// Both sample sets start at a multiple of three, so this split matches
// the helper's separate colour and extra-sample indices.
let candidate = fit_tone(
sensor
.data
.iter()
.zip(&reference.data)
.enumerate()
.filter(|(i, _)| i % 3 != 0)
.map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from))))
.collect(),
)
.unwrap();
let held: Pairs =
sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect();
assert_eq!(held.len(), 200);
assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y))));
assert!(rank_correlation_ties(&held) > 0.99);
let error = |tone: &CameraTone| {
held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::<f64>()
};
assert_ne!(candidate, original);
let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap();
if holdout_matches_original {
assert!(error(&candidate) > error(&original) + 0.1);
assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve");
} else {
assert!(error(&candidate) < error(&original) * 0.99);
assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate");
}
let mut previous = 0.0;
assert_eq!(selected.apply(0.0), 0.0);
for i in 0..10_000 {
let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0));
assert!(y.is_finite() && y >= previous && y <= 1.0);
previous = y;
}
assert_eq!(selected.apply(f32::NAN), 0.0);
assert_eq!(selected.apply(f32::INFINITY), 0.0);
}
}
#[test]
fn unreliable_dark_pairs_keep_the_original_tone_and_colour() {
let (sensor, reference) = steep_toe_scene();
let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect();
let mut excluded = sensor.clone();
for &i in &dark {
excluded.data[i] = [f32::NAN; 3];
}
let colour = Some((Mat3::IDENTITY, None));
let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap();
for case in 0..6 {
let (mut x, mut y) = (sensor.clone(), reference.clone());
for (j, &i) in dark.iter().enumerate() {
match case {
0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal
1 => x.data[i] = [0.00001; 3], // below the sensor noise floor
2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs
3 => x.data[i] = [0.003; 3], // no scene luminance spread
4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe
5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise
_ => {}
}
}
let look = fit_pairs_with(&x, &y, colour.clone()).unwrap();
assert_eq!(look.tone, original.tone, "case {case}");
assert_eq!(look.matrix, original.matrix, "case {case}");
assert!(look.hue_sat.is_none());
}
}
#[test]
fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
// An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
@@ -1834,17 +2137,19 @@ mod tests {
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let own = lightcraft_raw::color::has_matrix(&raw.color);
let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some();
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
let measured = lightcraft_raw::spectral::find(make, model).is_some();
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
let before = raw.color.clone();
let (_, t, fitted) = starting_colour(&mut raw, &bytes);
let spectral = !own && fitted.is_none() && covered;
let spectral = !own && fitted.is_none() && measured;
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
assert_eq!(raw.color != before, spectral, "{name}");
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
// spectral matrices, whichever look it got (issue #730)
// white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin,
// through the spectral matrices or its presets, whichever look it got (issue #730)
assert_eq!(info.relative_wb, !own && !covered, "{name}");
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
if covered && !own {
@@ -2062,6 +2367,43 @@ mod tests {
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same
/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation
/// table and its tone curve; without a look, those of the camera transform.
#[test]
fn dng_colour_renders_like_the_raws_starting_colour() {
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37));
assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb);
let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
for look in [Some(&look), None] {
let dng = dng_colour(&ColorData::default(), &t, look).unwrap();
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
for i in 0..3 {
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
}
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
for c in samples {
let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c));
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}");
}
assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone()));
let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve);
match look {
Some(look) => {
let tone = tone.expect("the look's tone curve");
let worst =
(0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max);
// (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here)
assert!(worst < 4e-3, "{worst}");
}
None => assert!(tone.is_none()),
}
}
}
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
CameraLook {
@@ -2317,6 +2659,36 @@ mod tests {
}
}
/// Sony ARWs without measured matrices read Kelvin from their white-balance presets
/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and
/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its
/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files.
#[test]
fn corpus_arw_white_balance_in_kelvin_through_the_presets() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let header = crate::files::probe_bytes(name, &bytes).unwrap();
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}");
assert!(
(info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0,
"{name}: {} {}",
info.as_shot_temp,
info.as_shot_tint
);
let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default());
assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}");
}
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
@@ -2810,4 +3182,20 @@ mod tests {
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
}
/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted.
#[test]
fn corpus_arw6_full_frame_gets_a_camera_look() {
const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) };
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/arw6-sony-ilce7rm6-ff-land-c.arw");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
}
+36 -13
View File
@@ -124,15 +124,14 @@ pub fn specs() -> Vec<CommandSpec> {
/// the library and stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let 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,
@@ -157,6 +156,31 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
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
@@ -165,14 +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 ext = format!(".{}", format.extension());
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}))
}
@@ -363,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Edit Copy for External Editor",
[],
None,
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render the active photo with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) 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
),
+14 -3
View File
@@ -303,9 +303,20 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
}
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
let sibling = src.with_file_name("denoise-model.json");
let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else {
let why = if sibling.is_file() {
"denoise-model.json could not be read or is too large"
} else if src.with_file_name("model.json").is_file() {
// other apps ship their models with a model.json of their own (#1381)
"No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \
does not read: it needs its own denoise-model.json (see docs/denoise.md)"
} else {
"No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)"
};
return Err(bad(C, why));
};
let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?;
let dir = models_dir(s, C)?;
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
+68 -7
View File
@@ -1079,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| {
@@ -1104,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())
}
}
+93 -2
View File
@@ -78,8 +78,76 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(list(s))
}
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
#[cfg(not(target_arch = "wasm32"))]
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
match &s.apple_photos {
Some(_) => Ok(()),
None => Err(crate::apple_photos::unavailable().into()),
}
}
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
/// worker thread (the default in the desktop app, which answers control requests on its UI
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
#[cfg(not(target_arch = "wasm32"))]
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.addToPhotos";
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
}
let paths: Vec<String> = match p.get("paths") {
Some(Value::Array(a)) => a
.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<_>>()
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
};
let album = match p.get("album") {
None | Some(Value::Null) => "",
Some(Value::String(a)) => a.as_str(),
Some(_) => return Err(bad(ID, "`album` must be a string")),
};
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
// in the middle of the import
let wait = match p.get("wait") {
None | Some(Value::Null) => !s.apple_photos_background,
// the desktop app answers on its window's thread, which never waits for Photos
Some(Value::Bool(true)) if s.apple_photos_background => {
return Err(bad(
ID,
"`wait: true` isn't available in the desktop app: the import runs in the background; its outcome comes from export.photosImports {job}",
));
}
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
};
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait, Some(&s.activity)).map_err(|e| bad(ID, e))?;
match job.outcome() {
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
_ => Ok(job.json()),
}
}
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
#[cfg(not(target_arch = "wasm32"))]
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.photosImports";
match p.get("job") {
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
Some(v) => {
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
}
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
#[allow(unused_mut)]
let mut v = vec![
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
always, |s, p| {
@@ -123,5 +191,28 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": path}))
}
),
]
];
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
// replay must not import again.
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
query "export.addToPhotos",
"Add to Apple Photos",
[],
None,
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app, where wait: true is refused (its control requests are answered on the UI thread, which never waits for Photos). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
photos_enabled,
add_to_photos
));
#[cfg(not(target_arch = "wasm32"))]
v.push(cmd!(
query "export.photosImports",
"Apple Photos Imports",
[],
None,
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
always,
photos_imports
));
v
}
+31 -3
View File
@@ -219,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();
@@ -641,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;
@@ -1247,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})) };
@@ -1303,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| {
@@ -1365,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?: 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, 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)?;
+9 -8
View File
@@ -195,20 +195,21 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| {
Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect()))
}),
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| {
Ok(Value::Array(
crate::presets::PROFILES
.iter()
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)}))
.collect(),
))
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile (built-in, then imported LUTs marked `imported`) with its group and favourite flag", always, |s, _| {
let favorite = |id: &str| s.profile_favorites.iter().any(|f| f == id);
let builtin = crate::presets::PROFILES.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(p.id)}));
// imported LUT profiles too (#618), marked as in `profiles.menu`
let imported =
s.lut_profiles.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(&p.id), "imported": true}));
Ok(Value::Array(builtin.chain(imported).collect()))
}),
cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())),
cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| {
cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| {
let id = photo_arg(s, p, "history.list")?;
let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?;
Ok(json!({
"history": ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"cursor": ph.history_index(),
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
+14 -4
View File
@@ -35,11 +35,13 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut read = Vec::new();
let mut failed = Vec::new();
let mut warnings = Vec::new();
for id in s.targets(p) {
match s.read_sidecar_op(id) {
Ok(Some((op, from))) => {
match s.read_sidecar_op_with_warnings(id) {
Ok(Some((op, from, mut photo_warnings))) => {
ops.push(op);
read.push(json!({"id": id.0, "from": from.display().to_string()}));
warnings.append(&mut photo_warnings);
}
Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})),
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
@@ -47,8 +49,16 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
}
if !ops.is_empty() {
s.commit("Read Metadata from File", Op::Batch { ops })?;
// Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had
// only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's
// next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them.
s.skip_auto_write = true;
}
Ok(json!({"read": read, "failed": failed}))
let mut result = json!({"read": read, "failed": failed});
if !warnings.is_empty() {
result["warnings"] = serde_json::to_value(warnings).unwrap_or_default();
}
Ok(result)
}
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
@@ -84,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Metadata from File",
["Photo"],
None,
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed}",
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
has_selection,
read
),
+16
View File
@@ -505,6 +505,22 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
(out, unmapped)
}
/// The selected camera profile name from an XMP packet. The name is metadata only: it does not
/// identify or carry the profile's rendering data.
pub fn camera_profile_name(props: &Props) -> Option<&str> {
first(props, "crs:CameraProfile")
}
/// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a
/// coordinate convention that LightCraft does not currently import.
pub fn has_saved_upright_transform(props: &Props) -> bool {
props.keys().any(|key| {
key.strip_prefix("crs:").is_some_and(|name| {
name.starts_with("UprightTransform_") && props.get(key).is_some_and(|values| values.iter().any(|v| !v.trim().is_empty()))
})
})
}
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
/// Returns `None` when the packet has no adjustments we understand.
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
+387 -20
View File
@@ -1,11 +1,11 @@
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF (or copy the original / write a
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF / JPEG XL (or copy the original / write a
//! DNG, each with the edits in XMP) in sRGB, Display P3, Adobe RGB
//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the
//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
pub use lightcraft_codecs::TiffCompression;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, encode, icc};
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, JxlColour, JxlOptions, NamedSpace, Samples, encode, icc};
pub use lightcraft_codecs::{JxlEffort, TiffCompression};
use lightcraft_meta::{DateTime, Gps, Metadata};
pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
@@ -22,10 +22,14 @@ pub enum ExportFormat {
Psd,
Webp,
Avif,
/// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ.
Jxl,
/// The original file, unchanged, with an XMP sidecar holding the edits.
Original,
/// A DNG of the raw data (raw photos only), the edits embedded as XMP.
Dng,
/// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in.
RenderedDng,
}
impl ExportFormat {
@@ -37,8 +41,10 @@ impl ExportFormat {
"psd" => Self::Psd,
"webp" => Self::Webp,
"avif" => Self::Avif,
"jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl,
"original" => Self::Original,
"dng" => Self::Dng,
"rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng,
_ => return None,
})
}
@@ -50,9 +56,10 @@ impl ExportFormat {
Self::Psd => "psd",
Self::Webp => "webp",
Self::Avif => "avif",
Self::Jxl => "jxl",
// the original keeps its own extension (see `ExportOptions::file_name_for`)
Self::Original => "",
Self::Dng => "dng",
Self::Dng | Self::RenderedDng => "dng",
}
}
@@ -619,11 +626,37 @@ pub enum MetadataPolicy {
None,
}
/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or
/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HdrCurve {
#[default]
Pq,
Hlg,
}
impl HdrCurve {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"pq" => Some(Self::Pq),
"hlg" => Some(Self::Hlg),
_ => None,
}
}
fn codec(self) -> lightcraft_codecs::HdrTransfer {
match self {
Self::Pq => lightcraft_codecs::HdrTransfer::Pq,
Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportOptions {
pub format: ExportFormat,
/// 1–100 (JPEG, AVIF).
/// 1–100 (JPEG, AVIF, lossy JPEG XL).
pub quality: u8,
/// Output size (`None` = full size).
pub resize: Option<Resize>,
@@ -654,18 +687,31 @@ pub struct ExportOptions {
pub remove_location: bool,
/// Text watermark (none when absent or the text is empty).
pub watermark: Option<Watermark>,
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile).
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile; lossy JPEG XL
/// writes Adobe RGB and ProPhoto as Rec. 2020, see [`ExportOptions::effective_space`]).
pub color_space: OutputSpace,
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
/// Bits per channel: 8, 16 (PNG, TIFF, JPEG XL), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
pub bit_depth: Option<u8>,
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
/// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
/// without cICP support show it as sRGB, washed out), 32-bit float
/// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float
/// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR
/// edit, are SDR.
pub hdr: bool,
/// JPEG XL: mathematically lossless instead of lossy (`quality` is then unused).
pub jxl_lossless: bool,
/// JPEG XL: encoder effort (time spent for a smaller file).
pub jxl_effort: JxlEffort,
/// JPEG XL: progressive file (a coarse image first, refined as it loads).
pub jxl_progressive: bool,
/// After the export, add the files written to Apple Photos (macOS only; see [`AfterExport`]).
pub add_to_photos: bool,
/// The Photos album they go into, made when missing (empty = none).
pub photos_album: String,
/// The curve of an HDR AVIF: PQ (default) or HLG.
pub hdr_transfer: HdrCurve,
}
impl Default for ExportOptions {
@@ -690,6 +736,12 @@ impl Default for ExportOptions {
color_space: OutputSpace::Srgb,
bit_depth: None,
hdr: false,
jxl_lossless: false,
jxl_effort: JxlEffort::Normal,
jxl_progressive: false,
add_to_photos: false,
photos_album: String::new(),
hdr_transfer: HdrCurve::Pq,
}
}
}
@@ -726,8 +778,20 @@ pub const OPTION_PARAMS: &[&str] = &[
"colorSpace",
"bitDepth",
"hdr",
"jxlLossless",
"jxlEffort",
"jxlProgressive",
"addToPhotos",
"photosAlbum",
"hdrTransfer",
];
/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the
/// session has the runner; see [`crate::Session::after_export`]).
pub fn apple_photos_unsupported() -> Option<&'static str> {
(!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS")
}
/// The keys of a `watermark` object ([`Watermark`], camelCase). `inset` sets `insetX` and `insetY`
/// together (what versions before the two margins wrote); either of those, when given, wins.
pub const WATERMARK_PARAMS: &[&str] =
@@ -842,7 +906,7 @@ impl ExportOptions {
match k.as_str() {
"format" => {
if ExportFormat::parse(string(cmd, k, v)?).is_none() {
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original")));
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original")));
}
}
"quality" => {
@@ -872,16 +936,30 @@ impl ExportOptions {
}
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
}
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
"dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => {
boolean(k, v)?;
}
"addToPhotos" => {
if boolean(k, v)?
&& let Some(why) = apple_photos_unsupported()
{
return Err(bad(format!("`{k}`: {why}")));
}
}
"photosAlbum" => {
if string(cmd, k, v)?.contains('\0') {
return Err(bad(format!("`{k}` can't contain a NUL character")));
}
}
"naming" | "subfolder" | "path" | "dir" | "preset" => {
string(cmd, k, v)?;
}
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
"dngCompression" => {
serde_json::from_value::<DngCompression>(v.clone())
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
@@ -965,7 +1043,8 @@ impl ExportOptions {
}
/// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`,
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`,
/// `addToPhotos`, `photosAlbum`) and the
/// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H),
/// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus
/// `dontEnlarge` (default true).
@@ -1011,6 +1090,12 @@ impl ExportOptions {
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless),
jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort),
jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive),
add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos),
photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()),
hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer),
}
}
@@ -1083,11 +1168,16 @@ impl ExportOptions {
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
(ExportFormat::Tiff, _) => OutputDepth::U16,
(ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::RenderedDng, _) => OutputDepth::F32Linear,
(ExportFormat::Psd, Some(8)) => OutputDepth::U8,
(ExportFormat::Psd, _) => OutputDepth::U16,
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
(ExportFormat::Avif, _) => OutputDepth::U8,
(ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16,
(ExportFormat::Jxl, _) => OutputDepth::U8,
}
}
@@ -1098,30 +1188,48 @@ impl ExportOptions {
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")],
ExportFormat::RenderedDng => &[(16, "16-bit float")],
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")],
}
}
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
/// or PNG (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF,
/// PNG or JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
pub fn hdr_output(&self) -> bool {
self.hdr
&& matches!(
(self.format, self.bit_depth),
(ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png, _) | (ExportFormat::Tiff, Some(32))
(ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32))
)
}
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR; HDR
/// PNG: Rec. 2020).
/// The colour space the file is actually written in (AVIF: sRGB; AVIF, PNG and JPEG XL HDR:
/// Rec. 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder
/// can declare without a profile).
pub fn effective_space(&self) -> OutputSpace {
match self.format {
ExportFormat::Avif | ExportFormat::Png if self.hdr => OutputSpace::Rec2020,
ExportFormat::Avif | ExportFormat::Png | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020,
ExportFormat::Avif => OutputSpace::Srgb,
ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => {
OutputSpace::Rec2020
}
_ => self.color_space,
}
}
/// The JPEG XL encoder settings for these options, writing samples in `space`.
fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions {
let colour = match space {
_ if hdr => JxlColour::Rec2020Pq,
OutputSpace::DisplayP3 => JxlColour::DisplayP3,
OutputSpace::Rec2020 => JxlColour::Rec2020,
// lossless files carry the ICC profile; lossy ones never get here (`effective_space`)
OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb,
};
JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour }
}
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
/// extension is kept for [`ExportFormat::Original`]).
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
@@ -1269,7 +1377,9 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
ExportFormat::Psd => encode::encode_psd(&e, &meta),
ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta),
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta),
f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()),
};
r.map_err(|e| e.to_string())
}
@@ -1360,6 +1470,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met
/// Encode a high-bit-depth image (see [`encode_rendered`]).
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let meta_src = meta;
let img = finish_deep(img, o);
let profile = match img.samples {
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
@@ -1378,10 +1489,18 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
(ExportFormat::Psd, _) => encode::encode_psd(&e, &meta),
(ExportFormat::RenderedDng, DeepSamples::F32(v)) => {
let m = meta_src.cloned().unwrap_or_default();
return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string());
}
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec())
}
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
(ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => {
lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta)
}
(ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta),
(f, _) => return Err(format!("{f:?} export is 8-bit only")),
};
r.map_err(|e| e.to_string())
@@ -1461,9 +1580,11 @@ pub struct Exported {
/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`).
pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) {
let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
// whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie
let (w, h) = (w.round().max(1.0), h.round().max(1.0));
match &o.resize {
Some(r) => r.apply(w, h),
None => ((w.round() as usize).max(1), (h.round() as usize).max(1)),
None => (w as usize, h as usize),
}
}
@@ -1667,6 +1788,104 @@ pub struct Destination {
pub exact: Option<String>,
}
/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or
/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by
/// [`crate::Session::after_export`] before the export starts, so an export asking for something
/// this session can't do fails before writing anything; for Photos it holds the import slot
/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when
/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its
/// worker.
#[derive(Default)]
pub struct AfterExport {
#[cfg(not(target_arch = "wasm32"))]
apple_photos: Option<crate::apple_photos::Reservation>,
}
impl AfterExport {
/// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving
/// the import slot of `imports` now. (Front ends get this from
/// [`crate::Session::after_export`], which checks the platform first.)
#[cfg(not(target_arch = "wasm32"))]
pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result<Self, String> {
Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) })
}
/// Nothing to do.
#[cfg(not(target_arch = "wasm32"))]
pub fn is_none(&self) -> bool {
self.apple_photos.is_none()
}
/// Nothing to do.
#[cfg(target_arch = "wasm32")]
pub fn is_none(&self) -> bool {
true
}
/// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written
/// count), waiting for Photos: for a worker thread (the app's background export) or a caller
/// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the
/// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false,
/// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the
/// export was cancelled or wrote nothing). Never fails the export: its files are written
/// either way.
pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option<serde_json::Value> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(r) = self.apple_photos {
return Some(r.run(files, cancelled));
}
let _ = (files, cancelled);
None
}
/// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the
/// outcome is the running job (`{job, running: true, requested, album}`; its result comes
/// from `export.photosImports`, and the desktop app announces it). For a caller on the UI
/// thread, which must not wait minutes for Photos.
pub fn start(self, files: &[serde_json::Value]) -> Option<serde_json::Value> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(r) = self.apple_photos {
return Some(r.start(files));
}
let _ = files;
None
}
/// [`AfterExport::start`] where the session answers on a UI thread
/// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`].
pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option<serde_json::Value> {
#[cfg(not(target_arch = "wasm32"))]
if s.apple_photos_background {
return self.start(files);
}
let _ = s;
self.run(files, false)
}
}
impl crate::Session {
/// What an export with `o` does after writing its files; an error, before anything is written,
/// when it asks for Apple Photos and this platform or session has no way to reach it, or
/// another import is reserved or running. For Photos the import slot is reserved from now on.
pub fn after_export(&self, o: &ExportOptions) -> Result<AfterExport, String> {
if !o.add_to_photos {
return Ok(AfterExport::default());
}
if let Some(why) = apple_photos_unsupported() {
return Err(why.into());
}
#[cfg(not(target_arch = "wasm32"))]
{
let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?;
let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?;
after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone()));
Ok(after)
}
#[cfg(target_arch = "wasm32")]
Err("Apple Photos is only available on macOS".into())
}
}
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
pub fn export_batch(
session: &mut crate::Session,
@@ -2523,6 +2742,47 @@ mod tests {
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
}
#[test]
fn resize_size_survives_the_render() {
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
let cropped = |r: Resize, w: u32, h: u32, x1: f64| {
let mut p = named("IMG.ARW");
(p.width, p.height) = (w, h);
std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0);
let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() });
lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh))
};
let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0);
let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true };
// a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5
let hair = 1.0 - 1e-9;
assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333));
assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365));
assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333));
assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000));
// 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says
for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] {
for (w, h) in [(w, h), (h, w)] {
for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] {
for r in [
Resize::long_edge(value as u32),
mode(ResizeMode::ShortEdge, value, 0),
mode(ResizeMode::Width, value, 0),
mode(ResizeMode::Height, value, 0),
mode(ResizeMode::Dimensions, value, 1365),
mode(ResizeMode::Megapixels, value / 1000.0, 0),
mode(ResizeMode::Percent, value / 100.0, 0),
] {
let want = r.apply(w.into(), h.into());
assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}");
}
}
}
}
}
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
fn p3_green(space: OutputSpace) -> Rgba8 {
use lightcraft_color::{DISPLAY_P3, REC2020};
@@ -2664,6 +2924,75 @@ mod tests {
}
}
#[test]
fn jxl_options() {
assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl));
let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap();
assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true));
assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings");
assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err());
assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err());
let space = |lossless, color_space, hdr| {
ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space()
};
assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3);
assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum");
assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile");
assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020);
let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() };
assert!(o.hdr_output());
assert_eq!(o.effective_depth(), OutputDepth::F32Hdr);
assert_eq!(ExportFormat::Jxl.extension(), "jxl");
}
#[test]
fn jxl_every_space_round_trips() {
// wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files
for (lossless, space) in [
(false, OutputSpace::Srgb),
(false, OutputSpace::DisplayP3),
(false, OutputSpace::Rec2020),
(true, OutputSpace::DisplayP3),
(true, OutputSpace::AdobeRgb),
(true, OutputSpace::ProPhoto),
] {
let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() };
let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() };
let img = p3_green(space);
let bytes = encode_image(&img, &o).unwrap();
assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl));
let (got, _) = decoded_in_srgb(&bytes);
let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap());
for c in 0..3 {
assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}");
}
if space != OutputSpace::Srgb {
assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}");
}
}
}
#[test]
fn jxl_sixteen_bit() {
let o = ExportOptions {
format: ExportFormat::Jxl,
bit_depth: Some(16),
jxl_lossless: true,
color_space: OutputSpace::DisplayP3,
..Default::default()
};
assert_eq!(o.effective_depth(), OutputDepth::U16);
let (levels8, _) = distinct_levels(
&encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(),
);
let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap());
assert_eq!(d.bit_depth, 16);
assert!(levels > 10 * levels8, "{levels} vs {levels8}");
// lossy 16-bit encodes too
let lossy = ExportOptions { jxl_lossless: false, ..o };
assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok());
}
#[test]
fn bit_depth_options_per_format() {
let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth();
@@ -2674,7 +3003,9 @@ mod tests {
assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16);
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16));
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None);
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] {
assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16);
assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8);
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] {
let first = ExportOptions::bit_depths(f)[0].0;
let def = d(f, None);
assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default");
@@ -2743,6 +3074,22 @@ mod tests {
j[k] = v;
}
assert_eq!(ExportOptions::from_params(&j).unwrap(), full);
// Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is
assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false"));
assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string"));
assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL"));
assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`"));
let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() };
assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album);
let on = ExportOptions { add_to_photos: true, ..album };
if cfg!(target_os = "macos") {
assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on);
} else {
assert!(err(on.to_json()).contains("Apple Photos is only available on macOS"));
// stored settings (a preset or Export with Previous made on a Mac) still read
assert!(ExportOptions::from_json(&on.to_json()).add_to_photos);
}
assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip");
assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default());
// null is "not given", as a dropped key is
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
@@ -2849,6 +3196,26 @@ mod tests {
}
// Issue #496: photos rendered side by side give the same files, names, order and progress as
// Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture
#[test]
fn rendered_dng_matches_the_rendered_tiff() {
let mut s = crate::Session::with_demo();
let id = s.visible()[0];
let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap();
assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name);
let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap();
let raw = lightcraft_raw::decode(&dng.bytes).unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3));
assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one");
let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image;
assert_eq!((reference.width, reference.height), (back.width, back.height));
// mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB
let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::<f32>() / back.data.len() as f32;
let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::<f32>() / reference.data.len() as f32;
assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}");
}
// one at a time, and a cancel stops at the same photo
#[test]
fn side_by_side_batches_match_one_at_a_time() {
+581 -22
View File
@@ -176,6 +176,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
kind: MediaKind::Raw,
file_size: bytes.len() as u64,
captured,
file_modified: None,
meta,
as_shot_wb,
content_hash,
@@ -207,6 +208,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
kind: MediaKind::Image,
file_size: bytes.len() as u64,
captured,
file_modified: None,
meta,
as_shot_wb: None,
content_hash,
@@ -508,15 +510,16 @@ fn load_bytes_now(
///
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look
/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
struct WbScaleOf {
own_matrix: bool,
relative: bool,
/// Kelvin through the camera's measured matrices: edited on the relative scale before those were used.
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
legacy_relative: bool,
/// The as-shot white through the measured matrices, when that is where the scale comes from.
spectral_white: Option<lightcraft_color::Xy>,
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
scale_white: Option<lightcraft_color::Xy>,
}
impl WbScaleOf {
@@ -528,18 +531,26 @@ impl WbScaleOf {
) -> Self {
let own_matrix = lightcraft_raw::color::has_matrix(color);
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten();
let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers));
WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white }
let model = meta.model.as_deref().filter(|_| fitted);
let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model));
let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model));
let scale_white = match camera {
Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers),
None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers),
None => None,
};
let covered = camera.is_some() || presets;
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
}
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
/// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog
/// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65.
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
/// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to
/// `own`, D65.
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
if self.relative {
return (6500.0, 0.0);
}
let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own));
let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own));
(t.round(), tint.round())
}
}
@@ -643,9 +654,16 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
/// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
let jpeg = stand_in_image(bytes)?;
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
let (mut w, mut h) = (d.source_width, d.source_height);
if preview_orientation(bytes, d.orientation).swaps_axes() {
// headers only, as for other images (issue #367): decoding a full-size preview just for its
// size took about a second per file in an import; a decode remains the fallback
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
Ok(header) => (header.width, header.height, header.orientation),
Err(_) => {
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
(d.source_width, d.source_height, d.orientation)
}
};
if preview_orientation(bytes, orientation).swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
Some((w, h))
@@ -680,17 +698,256 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
/// Filesystem-backed embedded-preview hook (native).
///
/// Reads the whole file to find the preview, so it holds the memory gate for twice the file's size (the file and the
/// decoded preview): background work (the face scan) waits for room, interactive work is counted and never waits.
/// The quick path reads the file's header and its embedded preview and nothing else (see
/// [`preview_window`]); a container the header cannot point into is planned from its tags
/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a
/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts
/// the whole file, which is what the decode of one can hold.
pub fn fs_preview_loader() -> PreviewLoader {
Arc::new(|path: &str, max_edge: usize| {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
return Some(image);
}
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
})
}
/// What [`preview_window`] read from a file, so a caller can see what a preview cost.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PreviewRead {
/// Bytes read.
pub read: u64,
/// The file's size.
pub total: u64,
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
/// every preview cost before the header path existed).
pub header_path: bool,
/// Whether the sparse planner named the ranges, rather than a format's own header fields: the
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
/// the header path, and on the whole-file fallback).
pub planned: bool,
}
impl PreviewRead {
/// The share of the file that was read, `0.0`..=`1.0`.
pub fn fraction(self) -> f64 {
if self.total == 0 {
return 0.0;
}
(self.read as f64 / self.total as f64).clamp(0.0, 1.0)
}
}
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
/// which is the caller's cue to read the file whole.
///
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview`
/// still needs the whole container, so the header comes back with the window; for a preview taken
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
/// whole-file fallback stays in charge.
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
let mut prefix = read_prefix(&mut file, path, total)?;
// What the header itself declares, before the container reader is asked: it clamps a slice to
// the buffer it was given, so a prefix that stops inside the declared preview yields a
// self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF:
// a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent
// first costs nothing — it lives in the first bytes of every format covered here.
if let Some(extent) = raf_preview_extent(&prefix)
&& extent > prefix.len() as u64
{
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
}
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else {
// The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
// sparse planner which ranges `embedded_preview` reads before handing the caller the
// whole-file fallback.
return planned_preview(&mut file, path, total);
};
// A preview at the file's end may have been padded past it: clamp rather than fail.
let end = range.offset.saturating_add(range.window_len).min(total);
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
// When the prefix already covers the range (the usual case: the prefix was grown to the declared
// extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview.
let prefix_len = prefix.len() as u64;
let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec);
let (window, bytes_read) = match from_prefix {
Some(window) => (window, prefix_len),
None => {
let mut window = vec![0u8; len];
file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?;
file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?;
(window, prefix_len + len as u64)
}
};
// A lying pointer must not cost the user their preview: hand the caller the whole-file fallback.
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
return Ok(None);
}
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
// On a network share the bytes are the wait, so say what this preview actually cost. A window
// taken from the prefix cost nothing beyond the prefix itself.
if lightcraft_pipeline::profiling() {
eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len());
}
Ok(Some((prefix, window, read)))
}
/// The most times the sparse planner may name ranges before the preview gives up: the plan is
/// monotone (it never asks for a range twice), so a real file needs a handful.
const MAX_PREVIEW_PLAN_ROUNDS: usize = 8;
/// Tenths of a file the planner may have read before the preview gives up and the whole-file path
/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the
/// file has stopped being one.
const MAX_PREVIEW_PLAN_SHARE: u64 = 6;
/// The largest file the planner path builds its buffer for. The raws it serves are far below this;
/// a file past it is read whole as before rather than allocating its length up front.
const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30;
/// The header the quick path is handed on the planner route. The header route grows its prefix to
/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out
/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough
/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes.
const PLAN_PREFIX: u64 = 64 * 1024;
/// The preview of a container the header-only path cannot answer for, from the byte ranges
/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads.
///
/// The planner is asked about a buffer of the file's whole length in which only the ranges named so
/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The
/// buffer is the file's length, which the loader's memory gate already counts, and its untouched
/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in
/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so
/// this path's cost is the bytes read, not a promise about RSS.
///
/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the
/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it
/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when
/// the quick path could not reproduce what the whole-file path shows (the two container facts
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
/// gives what the whole file gives), so a window that still fails to decode ends in the same
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
if total > MAX_PREVIEW_PLAN_BYTES {
return Ok(None);
}
let Ok(len) = usize::try_from(total) else { return Ok(None) };
let mut buf = vec![0u8; len];
let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?;
let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) };
head.copy_from_slice(&header);
let mut loaded = lightcraft_raw::sparse::Ranges::new();
loaded.insert(0, header.len() as u64);
for _ in 0..MAX_PREVIEW_PLAN_ROUNDS {
match lightcraft_raw::sparse::preview_plan(&buf, &loaded) {
lightcraft_raw::sparse::HeaderPlan::Need(ranges) => {
let wanted = loaded.total().saturating_add(ranges.total());
if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) {
return Ok(None);
}
for &(start, end) in ranges.spans() {
let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) };
let Some(dst) = buf.get_mut(a..b) else { return Ok(None) };
file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?;
file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?;
loaded.insert(start, end);
}
}
lightcraft_raw::sparse::HeaderPlan::Ready => {
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
// The quick path hands the JPEG to the decoder alone, so what the *container* says
// about the preview has to be something the bytes it is handed can show. Two container
// facts change the pixels and are not in the JPEG: the preview's colour space (which
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
// no orientation of its own, the file's EXIF orientation. Where either is present and
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
// it already does for a container-declared colour space. A container that says sRGB
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
// space declines.
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
return Ok(None);
}
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
// first: a JPEG that carries its own orientation needs nothing from the container.
if lightcraft_meta::extract(&window).orientation.is_none()
&& lightcraft_meta::extract(&header).orientation.is_none()
&& lightcraft_meta::extract(&buf).orientation.is_some()
{
return Ok(None);
}
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
// A plan-driven preview is the one that used to cost the whole file, so it is worth
// saying what it cost now too.
if lightcraft_pipeline::profiling() {
eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len());
}
// The planner's buffer is the file's length, so give its pages back the way a decode
// does (see `fs_pair_loader`).
drop(buf);
crate::memory::release();
return Ok(Some((header, window, read)));
}
lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None),
}
}
Ok(None)
}
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX));
read_prefix_at(file, path, len as u64)
}
/// The file's first `len` bytes, read from the start.
fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result<Vec<u8>, String> {
use std::io::{Read, Seek, SeekFrom};
let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?;
let mut prefix = vec![0u8; len];
file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?;
file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?;
Ok(prefix)
}
/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at
/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here
/// without the rest of the file so the prefix can be made big enough first.
fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
if !prefix.starts_with(b"FUJIFILMCCD-RAW") {
return None;
}
let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from);
let (offset, len) = (u32_at(84)?, u32_at(88)?);
(offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten()
}
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
/// `None` when the bytes are not a decodable image.
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
}
// The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does
Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation)))
}
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
pub fn fs_pair_loader() -> PairLoader {
@@ -729,19 +986,34 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let _permit = crate::memory::work_gate().acquire(len);
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
probe_bytes(path, &bytes)
let mut info = probe_bytes(path, &bytes)?;
info.file_modified = file_modified(path);
Ok(info)
});
(loader, probe)
}
/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the
/// filesystem doesn't say or the time is before 1970.
fn file_modified(path: &str) -> Option<String> {
let t = std::fs::metadata(path).ok()?.modified().ok()?;
let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?;
Some(crate::import::civil(secs))
}
impl crate::Session {
/// Install the filesystem file hooks (desktop, CLI, MCP headless).
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
pub fn with_fs(mut self) -> Self {
let (l, p) = fs_hooks();
self.media.file_loader = Some(l);
self.media.file_probe = Some(p);
self.media.preview_loader = Some(fs_preview_loader());
self.media.denoise.loader = Some(fs_pair_loader());
#[cfg(target_os = "macos")]
{
self.apple_photos = Some(crate::apple_photos::osascript());
}
self
}
}
@@ -776,33 +1048,278 @@ mod tests {
let d65 = lightcraft_color::D65;
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some());
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some());
let (t, tint) = a7m3.as_shot(d65);
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
assert!(bare.legacy_relative && bare.spectral_white.is_none());
assert!(bare.legacy_relative && bare.scale_white.is_none());
assert_eq!(bare.as_shot(d65), {
let (t, tint) = xy_to_temp_tint(d65);
(t.round(), tint.round())
});
// a camera without measured matrices: relative, whatever the multipliers
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none());
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none());
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
// a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as
// 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65)
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
(2500.0, g(1336.0, 3672.0)),
(3200.0, g(1720.0, 2620.0)),
(4500.0, g(2256.0, 1880.0)),
(6000.0, g(2680.0, 1544.0)),
(8500.0, g(3136.0, 1300.0)),
]);
let with = ColorData { kelvin_presets: presets, ..Default::default() };
let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2"));
assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some());
let (t, tint) = a99m2.as_shot(d65);
assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}");
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2"));
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2"));
assert!(bare.legacy_relative && bare.scale_white.is_none());
// presets in a file of a camera not listed: relative, as the catalog decides from the camera alone
let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1"));
assert!(other.relative && other.scale_white.is_none());
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none());
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none());
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
}
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
let head = 160;
let mut f = vec![0u8; head];
f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW ");
f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes());
f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes());
f.extend_from_slice(jpeg);
f.extend(std::iter::repeat_n(0x33u8, tail));
f
}
/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a
/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten
/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the
/// quick path produces must equal the whole-file path's, exactly.
#[test]
fn preview_loading_reads_only_the_header_and_the_jpeg() {
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let file = raf_with_embedded_jpeg(&jpeg, 200_000);
let dir = crate::tests_xmp::temp_dir("preview-window");
let path = dir.join("dscf0001.raf");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
assert!(read.header_path, "the header path is what answered");
assert!(!read.planned, "a RAF pointer in the header needs no planner");
assert_eq!(read.total, file.len() as u64);
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
// The quick path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
let plain = dir.join("plain.jpg");
std::fs::write(&plain, &jpeg).unwrap();
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the
/// file's own orientation, and the EXIF colour space the container declares for its preview.
#[derive(Clone, Copy, Default)]
struct Fixture {
dng: bool,
sony: bool,
orientation: Option<u16>,
/// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB.
color_space: Option<u16>,
}
/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the
/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut preview = IfdBuilder::new();
if what.dng {
preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
}
if what.sony {
preview.set(t::MAKE, Value::Ascii("SONY".into()));
}
if let Some(o) = what.orientation {
preview.set(t::ORIENTATION, Value::Short(vec![o]));
}
if let Some(space) = what.color_space {
let mut exif = IfdBuilder::new();
exif.set(0xA001, Value::Short(vec![space]));
if space == 0xFFFF {
let mut interop = IfdBuilder::new();
interop.set(0x0001, Value::Undefined(b"R03".to_vec()));
exif.set_child(t::INTEROP_IFD, interop);
}
preview.set_child(t::EXIF_IFD, exif);
}
preview.set(t::COMPRESSION, Value::Short(vec![7]));
preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
let mut raw = IfdBuilder::new();
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
TiffWriter::default().write(&[preview, raw]).expect("the fixture writes")
}
/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview
/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges
/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels
/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline
/// rather than guess, so the caller keeps reading it whole.
#[test]
fn preview_loading_plans_the_ranges_it_needs() {
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() });
let dir = crate::tests_xmp::temp_dir("preview-plan");
let path = dir.join("preview.dng");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
assert!(read.planned, "the planner is what answered");
assert!(read.header_path, "and it is still a bounded read");
assert_eq!(read.total, file.len() as u64);
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len());
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
// The plan path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
// The same structure without the tag that makes it a raw: the planner declines, and the
// caller's whole-file read is what answers (no guessing at what a file might hold).
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
std::fs::write(&path, &not_a_raw).unwrap();
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
let _ = std::fs::remove_dir_all(&dir);
}
/// A file whose preview the quick path could not show the way the whole-file path does is left to
/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is
/// bigger than that header) and the preview JPEG carries none of its own, so a window read would
/// come out unrotated where the whole-file path rotates it — the plan must decline instead of
/// handing on a wrong picture.
#[test]
fn preview_loading_declines_an_orientation_only_the_whole_file_shows() {
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
// the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts
// IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image
let mut strip = jpeg.clone();
strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize));
let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() });
let dir = crate::tests_xmp::temp_dir("preview-plan-orientation");
let path = dir.join("rotated.dng");
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().into_owned();
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
let _ = std::fs::remove_dir_all(&dir);
}
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
#[test]
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
use lightcraft_raw::PreviewColorSpace;
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let dir = crate::tests_xmp::temp_dir("preview-plan-space");
let path = dir.join("space.arw");
let path = path.to_string_lossy().into_owned();
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
std::fs::write(&path, &adobe).unwrap();
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
std::fs::write(&path, &srgb).unwrap();
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
assert!(read.planned, "the plan still answers");
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
}
/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as
/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time.
#[test]
fn undated_files_take_the_file_date() {
let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let rgb = vec![128u8; 16 * 16 * 3];
let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb));
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let path = dir.join("export.jpg");
std::fs::write(&path, &jpeg).unwrap();
let path = path.to_string_lossy().to_string();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let want = crate::import::civil(mtime as i64);
let (_, probe) = fs_hooks();
let info = probe(&path).unwrap();
assert_eq!(info.captured, None, "the file itself has no date");
assert_eq!(info.file_modified.as_deref(), Some(want.as_str()));
let mut s = crate::Session::new().with_fs();
let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap();
let id = lightcraft_catalog::PhotoId(r.imported[0]);
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
/// file or one that isn't an image.
@@ -834,6 +1351,17 @@ mod tests {
assert!(probe_bytes("x.jpg", b"not an image").is_err());
}
/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets
/// start from), not on the black its transparent pixels store.
#[test]
fn transparent_png_develops_on_white() {
let rgba = [200, 40, 40, 255, 0, 0, 0, 0];
let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap();
let (img, _) = load_bytes(&png, 64).unwrap();
assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0));
assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0));
}
/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample
/// of a block on a grey surface must not become green speckles (the block's green used to take the
/// clip level, which highlight reconstruction then kept).
@@ -1664,6 +2192,37 @@ mod tests {
assert!(e.contains("without an embedded preview"), "{e}");
}
/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606`
/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic.
#[test]
fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let mos = |leaf_data: bool| {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1]));
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] });
if leaf_data {
ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat()));
}
TiffWriter::default().write(&[ifd0]).unwrap()
};
let p = probe_bytes("aptus22.mos", &mos(true)).unwrap();
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30));
assert!(p.preview_only.is_some_and(|why| !why.is_empty()));
let (img, src) = load_bytes(&mos(true), 20).unwrap();
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
// the same grey image without Leaf's block is an ordinary 16-bit TIFF
let plain = probe_bytes("grey.tif", &mos(false)).unwrap();
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12));
}
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
#[test]
+124 -9
View File
@@ -210,6 +210,48 @@ pub struct ImportReport {
pub scanned: usize,
/// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP).
pub sidecars: usize,
/// Per-photo diagnostics from foreign XMP develop settings that could not be fully carried over.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ImportWarning>,
}
/// A structured diagnostic from foreign XMP develop settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImportWarningCode {
/// A named camera profile was found but cannot be applied as Lightroom does.
CameraProfile,
/// Lightroom geometry data was found but its saved transform is not retained.
UprightGeometry,
/// One or more other XMP develop fields have no LightCraft mapping.
UnmappedXmp,
}
/// The saved Lightroom Upright mode associated with a foreign geometry transform.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImportUprightMode {
Off,
Auto,
Level,
Vertical,
Full,
Guided,
Unknown,
}
/// One actionable XMP import diagnostic. `path` is filled when its photo is committed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportWarning {
pub path: String,
pub code: ImportWarningCode,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<ImportUprightMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
}
/// A file moved by an import.
@@ -424,10 +466,18 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp
/// Probe files (headers + content hash) as an import would.
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
let progress = ScanProgress::default();
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
}
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
fn probe_all(
probe: Option<&crate::media::FileProbe>,
paths: &[String],
done: &std::sync::atomic::AtomicUsize,
cancel: &std::sync::atomic::AtomicBool,
) -> Vec<Result<ProbeInfo, String>> {
use std::sync::atomic::Ordering::Relaxed;
let Some(probe) = probe.cloned() else {
return paths
@@ -455,11 +505,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
if i >= paths.len() {
break;
}
if progress.cancel.load(Relaxed) {
if cancel.load(Relaxed) {
break;
}
let r = probe(&paths[i]);
progress.done.fetch_add(1, Relaxed);
done.fetch_add(1, Relaxed);
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
}
});
@@ -471,11 +521,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
paths
.iter()
.map(|p| {
if progress.cancel.load(Relaxed) {
if cancel.load(Relaxed) {
return Err("cancelled".into());
}
let r = probe(p);
progress.done.fetch_add(1, Relaxed);
done.fetch_add(1, Relaxed);
r
})
.collect()
@@ -578,7 +628,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
.collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
progress.done.store(todo.len() - missing.len(), Relaxed);
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter();
let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
@@ -599,6 +649,8 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
Some(Ok(info)) => {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
// as the import will date it (a sidecar's date, when there is one, still wins there)
c.captured = c.captured.or_else(|| info.file_modified.clone());
c.preview_only = info.preview_only.clone();
if let Some(h) = &info.content_hash {
match seen_hash.get(h) {
@@ -663,6 +715,10 @@ pub struct ImportJob {
/// Files found so far (folders expanded) and handled so far, for progress.
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
/// Of those, the files that have to be read first (not probed by a preceding scan) and the
/// ones read so far: the step before they are handled.
pub read_total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub read_done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
@@ -815,6 +871,8 @@ impl ImportJob {
now: (s.clock)(),
total: Default::default(),
done: Default::default(),
read_total: Default::default(),
read_done: Default::default(),
})
}
@@ -875,8 +933,10 @@ impl ImportJob {
})
.collect();
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
let progress = ScanProgress::default();
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
// Reading the files is the slow part of adding them where they are: it is counted on its
// own, and `cancel` stops it too (a large library takes long enough to want both).
self.read_total.fetch_add(missing.len(), Relaxed);
let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter();
let probed: Vec<Result<ProbeInfo, String>> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
@@ -932,6 +992,10 @@ impl ImportJob {
if info.captured.is_none() {
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
}
// no date in the file or its sidecar: the file's own date, as Lightroom does
if info.captured.is_none() {
info.captured = info.file_modified.clone();
}
let stored = match (mode, &self.copy_root) {
(ImportMode::Copy | ImportMode::Move, Some(root))
if !crate::import_move::inside(Path::new(&path), root)
@@ -1274,6 +1338,10 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
report.sidecars += 1;
report.warnings.extend(sc.warnings.iter().cloned().map(|mut warning| {
warning.path = path.clone();
warning
}));
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
@@ -1452,4 +1520,51 @@ mod prepared_tests {
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
}
fn unread_paths(n: usize) -> Vec<String> {
(0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect()
}
/// Reading the files of a large import (a Lightroom catalog's originals) reported nothing:
/// the progress stood still at "14 of 24554" for as long as it took.
#[test]
fn prepare_counts_the_files_it_reads() {
use std::sync::atomic::Ordering::Relaxed;
let mut s = Session::new();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)));
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
let (done, last) = (job.read_done.clone(), done_in_probe.clone());
job.probe = Some(std::sync::Arc::new(move |_: &str| {
seen_in_probe.fetch_add(1, Relaxed);
last.fetch_max(done.load(Relaxed), Relaxed);
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
}));
let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false));
assert_eq!(prepared.items.len(), 40);
assert_eq!(seen.load(Relaxed), 40);
assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read");
assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted");
assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own");
}
/// Cancel had no effect until every file had been read.
#[test]
fn cancel_stops_reading_files() {
use std::sync::atomic::Ordering::Relaxed;
let mut s = Session::new();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone());
job.probe = Some(std::sync::Arc::new(move |_: &str| {
seen_in_probe.fetch_add(1, Relaxed);
cancel_in_probe.store(true, Relaxed);
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
}));
let prepared = job.prepare(&unread_paths(400), &cancel);
// at most the files the (up to 8) readers had already started
assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed));
assert!(prepared.items.is_empty());
}
}
+54 -3
View File
@@ -41,14 +41,25 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
let crop =
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
let corr = lens.correction(&shot)?;
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
}
/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the
/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C /
/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor
/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the
/// difference is the tag's whole-millimetre rounding and the body's value stands.
fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -> Option<f64> {
let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c));
match (camera, frame) {
(Some(body), Some(frame)) if frame > body * 1.1 => Some(frame),
(body, frame) => body.or(frame),
}
}
/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`.
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
crate::files::embedded_lens(raw).or_else(|| {
@@ -98,6 +109,46 @@ mod tests {
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
}
/// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the
/// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's
/// corners don't get the full frame's corner correction.
#[test]
fn in_camera_crop_mode_uses_the_frames_crop_factor() {
let db = LensDb::parse(
r#"<lensdatabase>
<camera><maker>Sony</maker><model>ILCE-1M2</model><mount>Sony E</mount><cropfactor>1</cropfactor></camera>
<lens><maker>Sigma</maker><model>24-70mm F2.8 DG DN | Art 019</model><mount>Sony E</mount><cropfactor>1.0</cropfactor>
<calibration>
<distortion model="ptlens" focal="70" a="0.0019947" b="0.0035263" c="0.0005016" />
<vignetting model="pa" focal="70" aperture="2.8" distance="1000" k1="-0.4" k2="0" k3="0" />
</calibration>
</lens></lensdatabase>"#,
)
.unwrap();
let m = |f35: Option<f64>| lightcraft_meta::Metadata {
make: Some("SONY".into()),
model: Some("ILCE-1M2".into()),
lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()),
focal_length: Some(70.0),
focal_length_35mm: f35,
f_number: Some(2.8),
..Default::default()
};
let at = |crop: f64, w: f64, h: f64| {
let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h };
db.lenses[0].correction(&shot).unwrap()
};
let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap();
assert_eq!(aps_c, at(1.5, 5616.0, 3744.0));
assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame");
// full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor
for f35 in [Some(70.0), Some(71.0), None] {
assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}");
}
assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's");
assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored");
}
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
/// stage with straight lines.
#[test]
+39 -5
View File
@@ -15,6 +15,8 @@
extern crate lightcraft_denoise_core as lightcraft_denoise;
pub mod activity;
#[cfg(not(target_arch = "wasm32"))]
pub mod apple_photos;
pub mod availability;
pub(crate) mod camera_preview;
pub mod camera_profiles;
@@ -128,7 +130,8 @@ fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
| Op::SetMeta { id, .. }
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. } => Some(*id),
| Op::PushHistory { id, .. }
| Op::SetHistoryCursor { id, .. } => Some(*id),
Op::Batch { ops } if depth < 8 => {
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
let first = ids.next()??;
@@ -219,7 +222,8 @@ pub struct Session {
pub previous_active: Option<PhotoId>,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// Built-in then user presets; its generation tells savers and caches when it changed.
pub presets: presets::PresetList,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec<String>,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
@@ -293,6 +297,20 @@ pub struct Session {
/// Untouched Local records of folders not browsed for this many days are forgotten when the
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
pub forget_local_days: u32,
/// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by
/// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests
/// put a fake here.
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos: Option<apple_photos::Runner>,
/// This session's Apple Photos imports, running or finished (`export.photosImports`).
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos_imports: apple_photos::Imports,
/// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`,
/// `app.export` without `background`): set by the desktop app, which answers control requests
/// on its UI thread. Off (the default), they wait for Photos, so a one-shot process
/// (lightcraft-cli, a script) never ends in the middle of one.
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos_background: bool,
}
impl Default for Session {
@@ -338,7 +356,7 @@ impl Session {
library_folder: None,
previous_active: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
presets: presets::builtin(),
presets: presets::PresetList::new(presets::builtin()),
profile_favorites: Vec::new(),
profile_recent: Vec::new(),
journal: Vec::new(),
@@ -374,6 +392,12 @@ impl Session {
cache_mb: 0,
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
apple_photos: None,
#[cfg(not(target_arch = "wasm32"))]
apple_photos_imports: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
apple_photos_background: false,
}
}
@@ -859,11 +883,17 @@ impl Session {
}
}
if self.source == LibrarySource::RecentlyAdded {
// newest import first, whatever the sort (the grid groups by import day)
// newest import first, whatever the sort direction (the grid groups by import day);
// a batch stays in file order (name, then id)
let cat = &self.catalog;
visible.sort_by(|a, b| {
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
key(b).cmp(&key(a)).then(a.cmp(b))
let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str());
let by_name = match (name(a), name(b)) {
(Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y),
_ => std::cmp::Ordering::Equal,
};
key(b).cmp(&key(a)).then(by_name).then(a.cmp(b))
});
}
if self.source == LibrarySource::Missing {
@@ -977,6 +1007,8 @@ mod tests_folders;
#[cfg(test)]
mod tests_forget_local;
#[cfg(test)]
mod tests_history_cursor;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
@@ -997,6 +1029,8 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_presets_scale;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
+69 -10
View File
@@ -88,7 +88,8 @@ pub struct Library {
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
presets_written: String,
/// What presets.json holds (see [`PresetsSaved`]).
presets_saved: PresetsSaved,
view_written: Vec<u8>,
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
@@ -123,6 +124,53 @@ struct PresetsFile {
profile_recent: Vec<String>,
}
/// The state presets.json was last written (or read) from. Saving checks the preset list's
/// generation and the small profile lists before serializing anything: with a 10 000-preset
/// collection presets.json is ~40 MB, and serializing it after every command made each slider
/// tick take seconds.
#[derive(Default)]
struct PresetsSaved {
generation: u64,
profile_favorites: Vec<String>,
profile_recent: Vec<String>,
/// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten.
hash: u64,
}
impl PresetsSaved {
fn state_of(s: &Session) -> PresetsSaved {
PresetsSaved {
generation: s.presets.generation(),
profile_favorites: s.profile_favorites.clone(),
profile_recent: s.profile_recent.clone(),
hash: 0,
}
}
/// `s` changed its presets or profile lists since this state.
fn differs(&self, s: &Session) -> bool {
self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent
}
}
fn bytes_hash(b: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut h);
h.finish()
}
#[cfg(test)]
thread_local! {
static PRESETS_SERIALIZED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread serialized the preset list (tests).
#[cfg(test)]
pub(crate) fn presets_serialized() -> usize {
PRESETS_SERIALIZED.with(|c| c.get())
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct ViewFile {
@@ -187,6 +235,8 @@ struct PrefsFile {
}
fn presets_json(s: &Session) -> String {
#[cfg(test)]
PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1));
let presets = &s.presets;
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
@@ -414,8 +464,9 @@ impl Session {
for p in &mut self.presets {
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
}
let mut ids: std::collections::HashSet<String> = self.presets.iter().map(|p| p.id.clone()).collect();
for u in f.user {
if !self.presets.iter().any(|p| p.id == u.id) {
if ids.insert(u.id.clone()) {
self.presets.push(u);
}
}
@@ -447,7 +498,8 @@ impl Session {
{
self.selection = Selection::single(*first);
}
let presets_written = presets_json(self);
// presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes)
let presets_saved = PresetsSaved::state_of(self);
// photo ids are per library: drop decoded sources of the previous one
self.media.clear_sources();
if on_disk {
@@ -464,7 +516,7 @@ impl Session {
unsaved_error: None,
retry_at: None,
forgot_local: None,
presets_written,
presets_saved,
view_written,
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
warnings_reported: 0,
@@ -526,14 +578,21 @@ impl Session {
log::error!("library: compaction: {e}");
lib.last_error = Some(format!("compaction: {e}"));
}
let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json"));
if !due {
return Ok(());
}
let presets = presets_json(self);
let mut state = PresetsSaved::state_of(self);
state.hash = bytes_hash(presets.as_bytes());
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
log::error!("library: presets: {e}");
} else {
lib.presets_written = presets;
}
if state.hash == lib.presets_saved.hash {
lib.presets_saved = state;
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
// the state stays stale: the next save tries again
log::error!("library: presets: {e}");
} else {
lib.presets_saved = state;
}
Ok(())
}
+118 -23
View File
@@ -129,6 +129,65 @@ pub struct LightroomCompletion {
/// Alias used by hosts that call the result a commit.
pub type LightroomCommit = LightroomCompletion;
/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LightroomStep {
/// Reading the catalog's tables.
Catalog,
/// Reading the photos the catalog names (headers, content hash): nearly all of the time.
ReadPhotos,
/// Readying the photos that were read for the catalog.
AddPhotos,
}
/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it.
#[derive(Clone, Default)]
pub struct LightroomProgress {
/// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]).
pub catalog_total: Arc<AtomicUsize>,
pub catalog_done: Arc<AtomicUsize>,
read_total: Arc<AtomicUsize>,
read_done: Arc<AtomicUsize>,
add_total: Arc<AtomicUsize>,
add_done: Arc<AtomicUsize>,
/// The catalog is read: the photos are next.
photos: Arc<AtomicBool>,
}
impl LightroomProgress {
/// Reading the photos is nearly all of an import's time; the catalog and readying the photos
/// take the rest of [`LightroomProgress::fraction`].
const CATALOG_SHARE: f32 = 0.05;
const READ_SHARE: f32 = 0.85;
/// The step under way, with how much of it is done and its total (0 while not yet known).
pub fn step(&self) -> (LightroomStep, usize, usize) {
let load = |n: &AtomicUsize| n.load(Ordering::Relaxed);
if !self.photos.load(Ordering::Relaxed) {
return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total));
}
let (read_done, read_total) = (load(&self.read_done), load(&self.read_total));
let (add_done, add_total) = (load(&self.add_done), load(&self.add_total));
// nothing to read (every photo is already in the library) goes straight to adding
if read_done < read_total || (read_total == 0 && add_done == 0) {
(LightroomStep::ReadPhotos, read_done, read_total)
} else {
(LightroomStep::AddPhotos, add_done, add_total)
}
}
/// The whole preparation, 0..=1.
pub fn fraction(&self) -> f32 {
let (step, done, total) = self.step();
let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) };
match step {
LightroomStep::Catalog => Self::CATALOG_SHARE * part,
LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part,
LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part,
}
}
}
/// A Lightroom import detached from the owner thread.
pub struct LightroomJob {
path: PathBuf,
@@ -136,6 +195,7 @@ pub struct LightroomJob {
import: ImportJob,
token: LightroomLibraryToken,
archive_dir: Option<PathBuf>,
progress: LightroomProgress,
}
impl LightroomJob {
@@ -145,33 +205,25 @@ impl LightroomJob {
let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
let import = ImportJob::new(s, opts)?;
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
let progress = LightroomProgress {
read_total: Arc::clone(&import.read_total),
read_done: Arc::clone(&import.read_done),
add_total: Arc::clone(&import.total),
add_done: Arc::clone(&import.done),
..LightroomProgress::default()
};
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress })
}
pub fn total(&self) -> usize {
self.import.total.load(Ordering::Relaxed)
}
pub fn done(&self) -> usize {
self.import.done.load(Ordering::Relaxed)
}
pub fn progress(&self) -> (usize, usize) {
(self.done(), self.total())
/// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs.
pub fn progress(&self) -> LightroomProgress {
self.progress.clone()
}
pub fn token(&self) -> &LightroomLibraryToken {
&self.token
}
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
std::sync::Arc::clone(&self.import.total)
}
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
std::sync::Arc::clone(&self.import.done)
}
/// Finish an owner commit's deferred archive-index write on a worker.
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
finalization.finish()
@@ -182,14 +234,12 @@ impl LightroomJob {
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
self.import.total.store(1, Ordering::Relaxed);
self.import.done.store(0, Ordering::Relaxed);
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
let mut data =
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
lightroom_catalog::validate_for_job(&data).map_err(error)?;
if cancel.load(Ordering::Relaxed) {
return Err(error("Lightroom import cancelled"));
}
self.import.done.fetch_add(1, Ordering::Relaxed);
let paths: Vec<String> = data
.photos
.iter()
@@ -203,6 +253,9 @@ impl LightroomJob {
.filter(|photo| !Path::new(&photo.path).is_file())
.map(|photo| normalize(&photo.path))
.collect();
// the catalog step's last unit (validation, and looking for each original), then the photos
self.progress.catalog_done.fetch_add(1, Ordering::Relaxed);
self.progress.photos.store(true, Ordering::Relaxed);
let normal = self.import.prepare(&paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
@@ -335,6 +388,48 @@ mod tests {
}
}
/// One count for the whole import read "14 of 24554" while the photos were read, then twice
/// the number of photos: each step has its own count, and the whole only moves forward.
#[test]
fn progress_names_each_step_with_its_own_count() {
let p = LightroomProgress::default();
let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed);
assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0));
assert_eq!(p.fraction(), 0.0);
set(&p.catalog_total, 14);
set(&p.catalog_done, 7);
assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14));
let mut last = p.fraction();
assert!(last > 0.0);
let mut forward = |p: &LightroomProgress| {
let f = p.fraction();
assert!(f >= last && f <= 1.0, "{f} after {last}");
last = f;
};
set(&p.catalog_done, 14);
p.photos.store(true, Ordering::Relaxed);
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet");
forward(&p);
set(&p.add_total, 100);
set(&p.read_total, 100);
set(&p.read_done, 40);
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100));
forward(&p);
set(&p.read_done, 100);
set(&p.add_done, 25);
assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100));
forward(&p);
set(&p.add_done, 100);
forward(&p);
assert!(p.fraction() > 0.999);
// every photo already in the library: nothing to read
let known = LightroomProgress::default();
known.photos.store(true, Ordering::Relaxed);
set(&known.add_total, 5);
set(&known.add_done, 5);
assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5));
}
#[test]
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
let mut session = crate::Session::new();
+209 -17
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 60;
pub const RENDER_CACHE_VERSION: u64 = 62;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -223,6 +223,13 @@ impl DecodedSource {
#[derive(Clone)]
pub enum SourceRef {
Loaded(Box<DecodedSource>),
/// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of
/// the photo open in the loupe is cut from its preview-level source instead of decoding the
/// file again (a second or more for a 64 MP raw).
Fit {
from: Box<DecodedSource>,
max_edge: usize,
},
Demo {
scene: Box<lightcraft_scenes::Scene>,
max_edge: usize,
@@ -251,6 +258,13 @@ impl SourceRef {
pub fn load_source(&self) -> Result<DecodedSource, String> {
let image = match self {
SourceRef::Loaded(a) => return Ok((**a).clone()),
SourceRef::Fit { from, max_edge } => {
if from.image.width.max(from.image.height) <= *max_edge {
return Ok((**from).clone());
}
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
}
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
let plain = || match loader {
@@ -314,6 +328,8 @@ pub struct MediaCache {
full: Vec<(PhotoId, DecodedSource, u64)>,
/// How many full-resolution originals to keep (LRU, at least one).
full_capacity: usize,
/// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget.
on_screen: Option<PhotoId>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
@@ -391,6 +407,7 @@ impl Default for MediaCache {
previews: Vec::new(),
full: Vec::new(),
full_capacity: 1,
on_screen: None,
preview_capacity: 0,
budget,
file_loader: None,
@@ -447,6 +464,16 @@ impl MediaCache {
self.trim_full();
}
/// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays
/// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it
/// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once.
pub fn set_on_screen(&mut self, id: Option<PhotoId>) {
if self.on_screen != id {
self.on_screen = id;
self.enforce_budget();
}
}
fn trim_full(&mut self) {
while self.full.len() > self.full_capacity {
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
@@ -475,15 +502,20 @@ impl MediaCache {
/// Evict least recently used entries across the source caches and the rendered previews
/// until they fit the budget. The newest preview-level source (the photo on screen, or the
/// one just decoded) is never evicted here.
/// one just decoded) is never evicted here, nor is the original of the photo on screen
/// ([`Self::set_on_screen`]).
pub fn enforce_budget(&mut self) {
let mut held = self.held();
// The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB)
// nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and
// thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step.
let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum();
let mut held = self.held().saturating_sub(pinned);
while held > self.budget {
let newest_preview = self.previews.iter().map(|e| e.2).max();
let candidates = [
self.thumbs.oldest_tick().map(|t| (t, 0)),
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)),
self.rendered.oldest_tick().map(|t| (t, 3)),
];
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
@@ -625,6 +657,15 @@ impl MediaCache {
if cached && let Some(a) = self.get_source(p.id, level) {
return SourceRef::Loaded(Box::new(a));
}
// a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not
// with a denoised picture, which the thumbnail would lose
if cached
&& level == SourceLevel::Thumb
&& self.denoise.spec(p.id).is_none()
&& let Some(big) = self.get_source(p.id, SourceLevel::Preview)
{
return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() };
}
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
let max_edge = level.max_edge().min(match (&p.source, level) {
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
@@ -803,7 +844,31 @@ impl RenderJob {
self
}
/// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting
/// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace
/// it, so a few such panics used to stop every render).
pub fn run(self) -> RenderResult {
let failed = self.failed(String::new());
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed })
}
/// This job's result when it fails with `e`.
fn failed(&self, e: String) -> RenderResult {
RenderResult {
request_id: self.request_id,
source_key: self.source_key,
photo: self.photo,
level: self.level,
key: self.key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
}
}
fn run_unguarded(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get_at(self.cache_generation, *key)
{
@@ -846,7 +911,8 @@ impl RenderJob {
(Some(d), Some(_)) => {
let keep = self.view_cache.is_some() && self.request.quality == Quality::Full;
let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep);
if let Some(h) = h {
// (an HDR edit's HDR histogram stays)
if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) {
rendered.histogram = h;
}
srgb = s;
@@ -876,18 +942,7 @@ impl RenderJob {
display,
}
}
Err(e) => RenderResult {
request_id: self.request_id,
source_key: self.source_key,
photo: self.photo,
level: self.level,
key: self.key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
},
Err(e) => self.failed(e),
}
}
}
@@ -909,6 +964,22 @@ pub fn develop_reporting(
req: &RenderRequest,
stages: Option<&StageCache>,
gpu: bool,
) -> (Rendered, Option<String>) {
let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu);
// an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead
if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) {
rendered.histogram = h;
}
(rendered, fallback)
}
fn develop_image(
src: &Arc<Rgb32f>,
info: &SourceInfo,
s: &DevelopSettings,
req: &RenderRequest,
stages: Option<&StageCache>,
gpu: bool,
) -> (Rendered, Option<String>) {
// LUT profiles have no GPU stage: they render on the CPU
let fallback = if gpu
@@ -959,7 +1030,25 @@ pub struct QuickJob {
}
impl QuickJob {
/// The stand-in, from the first source that has one. A panic inside is this job's failure
/// (see [`RenderJob::run`]).
pub fn run(self) -> RenderResult {
let (request_id, photo, key) = (self.request_id, self.photo, self.key);
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult {
request_id,
source_key: None,
photo,
level: SourceLevel::Thumb,
key,
rendered: Err(e),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
})
}
fn run_unguarded(self) -> RenderResult {
let (photo, key) = (self.photo, self.key);
let request_id = self.request_id;
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
@@ -1295,6 +1384,23 @@ impl crate::Session {
}
}
/// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor
/// decode behind it.
///
/// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws
/// likewise start from the embedded preview"), and then paid for a render of the raw to replace
/// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px
/// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is
/// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind
/// it delayed the render the user was waiting for.
///
/// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what
/// says the photo still looks the way it did at import. A photo the user edits re-renders
/// through the ordinary path, because its thumbnail key (settings hash) changes.
pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool {
self.embedded_of(p).is_some()
}
/// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the
/// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the
/// raw. `None` for anything else, and where the host installed no loader.
@@ -1445,6 +1551,10 @@ pub struct ProbeInfo {
pub kind: MediaKind,
pub file_size: u64,
pub captured: Option<String>,
/// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the
/// capture date of a file whose metadata has none, as Lightroom does (exports saved without
/// metadata would otherwise all land under "Unknown Date").
pub file_modified: Option<String>,
pub meta: lightcraft_catalog::Meta,
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
@@ -1554,6 +1664,31 @@ mod thumbnail_hash_tests {
s.accept(&old);
assert_eq!(s.media.source_usage().0, 0);
}
/// A panic while rendering (here in the decoder) used to end the pool's worker thread without
/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now,
/// for renders and for stand-ins, answering the request it came from.
#[test]
fn a_render_that_panics_fails_instead_of_never_answering() {
let mut s = session();
s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug")));
let mut job = s.thumb_job(PhotoId(1), 256).unwrap();
job.request_id = 42;
let (key, level, source_key) = (job.key, job.level, job.source_key);
let r = job.run();
assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key));
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug"));
let quick = QuickJob {
request_id: 7,
photo: PhotoId(1),
key: 9,
cached: Vec::new(),
embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option<Rgba8> { panic!("preview reader bug") }), 256)),
small: None,
};
let r = quick.run();
assert_eq!((r.request_id, r.key), (7, 9));
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug"));
}
}
#[cfg(test)]
@@ -1759,6 +1894,45 @@ mod tests {
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay
// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render)
#[test]
fn the_on_screen_original_does_not_evict_the_other_sources() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Thumb, img(200));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] {
assert!(m.has_source(PhotoId(id), level), "{id} {level:?}");
}
}
// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB):
// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo
// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown.
#[test]
fn the_original_on_screen_stays_even_over_budget() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.set_on_screen(Some(PhotoId(2)));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
assert!(m.held() <= 20 * mb, "{}", m.held());
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
@@ -1897,6 +2071,24 @@ mod tests {
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
/// one nothing can decode) is not read again.
#[test]
fn a_thumbnail_is_cut_from_the_decoded_preview() {
let mut s = crate::Session::new();
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
let job = s.thumb_job(p.id, 512).unwrap();
assert!(matches!(job.source, SourceRef::Fit { .. }));
let r = job.run();
let thumb = r.rendered.as_ref().unwrap();
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
s.accept(&r);
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview");
}
#[test]
fn has_source_follows_what_the_session_accepted() {
let mut s = crate::Session::with_demo();
+3 -1
View File
@@ -110,7 +110,9 @@ impl MergeJob {
.par_iter()
.map(|(_, path)| {
let bytes = (self.read)(path)?;
let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}"));
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
.map_err(|e| format!("{path}: {e}"));
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
f
+81 -3
View File
@@ -23,6 +23,73 @@ pub struct PresetFile {
pub presets: Vec<Preset>,
}
/// The session's presets (built-in, then the user's) with a generation that changes on every
/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
///
/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
/// Generations are unique across all lists in the process.
#[derive(Clone, Debug)]
pub struct PresetList {
items: Vec<Preset>,
generation: u64,
}
static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_generation() -> u64 {
PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl PresetList {
pub fn new(items: Vec<Preset>) -> PresetList {
PresetList { items, generation: next_generation() }
}
/// Changes whenever the list may have changed.
pub fn generation(&self) -> u64 {
self.generation
}
}
impl Default for PresetList {
fn default() -> PresetList {
PresetList::new(Vec::new())
}
}
impl std::ops::Deref for PresetList {
type Target = Vec<Preset>;
fn deref(&self) -> &Vec<Preset> {
&self.items
}
}
impl std::ops::DerefMut for PresetList {
fn deref_mut(&mut self) -> &mut Vec<Preset> {
self.generation = next_generation();
&mut self.items
}
}
impl<'a> IntoIterator for &'a PresetList {
type Item = &'a Preset;
type IntoIter = std::slice::Iter<'a, Preset>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter()
}
}
impl<'a> IntoIterator for &'a mut PresetList {
type Item = &'a mut Preset;
type IntoIter = std::slice::IterMut<'a, Preset>;
fn into_iter(self) -> Self::IntoIter {
self.generation = next_generation();
self.items.iter_mut()
}
}
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
pub fn slug(s: &str) -> String {
let mut out = String::new();
@@ -107,22 +174,33 @@ impl Session {
/// Add imported presets. Identical presets (same name, group and settings) already present are
/// skipped; id clashes get a fresh id. Returns the ids added.
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
use std::collections::{HashMap, HashSet};
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
let mut by_name: HashMap<(String, String), Vec<usize>> = HashMap::new();
for (i, q) in self.presets.iter().enumerate() {
by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
}
let mut added = Vec::new();
for mut p in presets {
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
let key = (p.name.clone(), p.group.clone());
let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
if same {
continue;
}
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
let mut id = base.clone();
let mut n = 2;
while self.presets.iter().any(|q| q.id == id) {
while ids.contains(&id) {
id = format!("{base}-{n}");
n += 1;
}
p.id = id;
}
p.builtin = false;
ids.insert(p.id.clone());
by_name.entry(key).or_default().push(self.presets.len());
added.push(p.id.clone());
self.presets.push(p);
}
+88 -17
View File
@@ -319,6 +319,17 @@ pub(crate) trait MoveFs {
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
/// The folder lists an entry spelled exactly like `p` (letter case included). On a
/// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two
/// apart. When the folder can't be listed: `true`, so nothing is skipped.
fn listed(&self, p: &Path) -> bool {
let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true };
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
match std::fs::read_dir(dir) {
Ok(entries) => entries.flatten().any(|e| e.file_name() == name),
Err(_) => true,
}
}
/// Content observed in a packet, independent of its pathname or inode. Model volumes
/// override this; disk implementations stream a bounded buffer without retaining the XML.
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
@@ -366,15 +377,23 @@ impl MoveFs for RealFs {
}
#[cfg(not(unix))]
{
// no stable file id in std here: `b` is `a` under another spelling when they look
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
// no stable file id in std here: `a` and `b` are one file under two spellings when
// they look alike and the folder doesn't list both (on a case-sensitive folder
// holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the
// listed one: the answer must not depend on the argument order.
let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false };
let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase();
if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
return false;
}
let Ok(entries) = std::fs::read_dir(dir) else { return false };
!entries.flatten().any(|e| e.file_name() == name)
let (mut listed_a, mut listed_b) = (false, false);
for e in entries.flatten() {
let name = e.file_name();
listed_a |= name == name_a;
listed_b |= name == name_b;
}
!(listed_a && listed_b)
}
}
@@ -699,15 +718,14 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu
if fs.exists(t) && !is_respelling(fs, f, t) {
return Err(fail(format!("{to} already exists")));
}
let mut packets: Vec<RetainedPacket> = sidecars(from)
.into_iter()
.map(|(p, _, _)| p)
.filter(|p| fs.is_file(p))
.map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path })
.collect();
// An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the
// lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`.
let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc));
let mut packets: Vec<RetainedPacket> =
sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect();
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
let mut done: Vec<SidecarMove> = Vec::new();
let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| {
let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| {
let mut dst = crate::sidecar::sidecar_path(to, naming);
if upper {
dst = dst.with_extension("XMP");
@@ -1124,6 +1142,24 @@ pub(crate) mod tests {
use super::*;
use std::cell::RefCell;
/// Windows has no file id in std: two spellings of one file are the same file whichever is
/// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the
/// known `Trip.xmp`), and only the spelling on disk is listed.
#[cfg(windows)]
#[test]
fn same_file_spellings_are_symmetric_on_windows() {
let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp"));
std::fs::write(&real, b"packet").unwrap();
std::fs::write(&other, b"packet").unwrap();
assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real));
assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias));
assert!(RealFs.listed(&real) && !RealFs.listed(&alias));
std::fs::remove_dir_all(&dir).unwrap();
}
/// The cross-volume move's check compares files chunk by chunk: equal files across several
/// chunks, a difference in the last chunk, different lengths, a missing file.
#[test]
@@ -1171,7 +1207,10 @@ pub(crate) mod tests {
}
fn idx(&self, p: &Path) -> Option<usize> {
let p = Self::norm(p);
self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p })
self.files.borrow().iter().position(|(f, _)| {
let f = f.replace('\\', "/");
if self.ci { folded_path(&f) == folded_path(&p) } else { f == p }
})
}
/// The listing: (exact path, contents), sorted.
pub fn listing(&self) -> Vec<(String, String)> {
@@ -1196,7 +1235,7 @@ pub(crate) mod tests {
self.idx(a).is_some() && self.idx(a) == self.idx(b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
return Err(std::io::Error::other("injected failure"));
}
if self.exists(b) {
@@ -1207,7 +1246,7 @@ pub(crate) mod tests {
Ok(())
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
return Err(std::io::Error::other("injected failure"));
}
if self.exists(b) {
@@ -1223,6 +1262,10 @@ pub(crate) mod tests {
self.files.borrow_mut().remove(i);
Ok(())
}
fn listed(&self, p: &Path) -> bool {
let p = Self::norm(p);
self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p)
}
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
let stem = Self::norm(&sidecar.with_extension(""));
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
@@ -1293,7 +1336,7 @@ pub(crate) mod tests {
} else {
serde_json::json!({"path": from, "into": root.join("Archive")})
};
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") };
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") };
let before = s.catalog.to_snapshot();
let history = (s.undo.len(), s.redo.len());
assert!(s.execute(command, &params).is_err());
@@ -1421,6 +1464,34 @@ pub(crate) mod tests {
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
}
/// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed
/// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling
/// is the one reported and queued.
#[test]
fn case_insensitive_renames_keep_each_sidecars_spelling() {
let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]);
move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap();
move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap();
assert_eq!(
fs.listing(),
vec![
("/p/c.jpg".into(), "A".into()),
("/p/c.jpg.XMP".into(), "full".into()),
("/p/c.xmp".into(), "stem".into()),
("/p/d.jpg".into(), "B".into()),
("/p/d.xmp".into(), "mixed".into())
]
);
// the move of the sidecar's real spelling is the one attempted (and here fails): the photo
// goes back and nothing changed
let before = fs.listing();
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into()];
let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err();
assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}");
assert!(!e.moved && e.pending_sidecars.is_empty());
assert_eq!(fs.listing(), before);
}
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
/// their new names (an undoable partial rename), so nothing goes missing.
#[test]
+79 -3
View File
@@ -104,6 +104,9 @@ pub struct SidecarData {
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
pub captured: Option<String>,
pub develop: Option<DevelopPatch>,
/// Actionable diagnostics for foreign develop settings that were not fully carried over.
/// These are surfaced in the photo import report and never persisted in the catalog.
pub warnings: Vec<crate::import::ImportWarning>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -165,7 +168,65 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu
out.develop = match full {
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
None if crate::crs::has_adjustments(&d.properties) => {
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT);
// Lightroom's default profiles are named by nearly every sidecar; our own starting look stands
// in for them, so only another profile is worth a warning
let profile = crate::crs::camera_profile_name(&d.properties)
.filter(|name| !matches!(name.trim(), "Adobe Standard" | "Adobe Color"))
.map(str::to_string);
if let Some(name) = profile {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::CameraProfile,
profile: Some(name),
mode: None,
fields: Vec::new(),
});
}
let upright = crate::crs::has_saved_upright_transform(&d.properties);
if upright {
let mode = d
.properties
.get("crs:PerspectiveUpright")
.and_then(|values| values.first())
.map(String::as_str)
.map(|mode| match mode {
"0" => crate::import::ImportUprightMode::Off,
"1" => crate::import::ImportUprightMode::Auto,
"2" => crate::import::ImportUprightMode::Level,
"3" => crate::import::ImportUprightMode::Vertical,
"4" => crate::import::ImportUprightMode::Full,
"5" => crate::import::ImportUprightMode::Guided,
_ => crate::import::ImportUprightMode::Unknown,
})
.unwrap_or(crate::import::ImportUprightMode::Unknown);
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UprightGeometry,
profile: None,
mode: Some(mode),
fields: Vec::new(),
});
}
let other: Vec<&str> = unmapped
.iter()
.map(String::as_str)
.filter(|name| {
*name != "CameraProfile"
&& !name.starts_with("UprightTransform_")
&& !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion")
})
.collect();
if !other.is_empty() {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UnmappedXmp,
profile: None,
mode: None,
fields: other.into_iter().map(str::to_string).collect(),
});
}
Some(DevelopPatch::Partial(partial))
}
None => None,
};
@@ -505,7 +566,12 @@ impl Session {
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
// Keep an existing packet's spelling/style so foreign properties survive this save and
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred);
// On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder
// lists comes first, so the atomic write doesn't rename the packet.
use crate::rename::MoveFs;
let existing: Vec<PathBuf> = candidates.into_iter().filter(|path| path.is_file()).collect();
let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned();
let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred);
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
@@ -517,11 +583,21 @@ impl Session {
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
self.read_sidecar_op_with_warnings(id).map(|result| result.map(|(op, from, _)| (op, from)))
}
/// Like [`Session::read_sidecar_op`], retaining transient diagnostics for the command report.
pub fn read_sidecar_op_with_warnings(&self, id: PhotoId) -> Result<Option<(Op, PathBuf, Vec<crate::import::ImportWarning>)>> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let Some(orig) = file_path(p) else { return Ok(None) };
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
Ok(Some((self.sidecar_op(id, sc)?, from)))
let mut warnings = sc.warnings.clone();
for warning in &mut warnings {
warning.path = orig.to_string();
}
let op = self.sidecar_op(id, sc)?;
Ok(Some((op, from, warnings)))
}
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
@@ -1,6 +1,7 @@
//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history.
use super::*;
use crate::rename::{MoveFs, RealFs};
use crate::tests_xmp::{same_path_for_volume, same_path_or_file};
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
@@ -16,7 +17,7 @@ impl MoveFs for Faults {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.0.iter().any(|p| p == a) {
if self.0.iter().any(|p| same_path_or_file(p, a)) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move"))
} else {
RealFs.rename_no_replace(a, b)
@@ -261,8 +262,8 @@ fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() {
assert!(!second_packet.exists());
let pending = queues(&s);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].from, shared);
assert_eq!(pending[0].to, second_packet);
assert!(same_path_for_volume(&pending[0].from, &shared), "{:?}", pending[0].from);
assert!(same_path_for_volume(&pending[0].to, &second_packet), "{:?}", pending[0].to);
let catalog_before = s.catalog.to_snapshot();
let history_before = (s.undo.len(), s.redo.len(), s.commits());
@@ -1,6 +1,7 @@
//! Native files and genuine Session entry points: packet ownership survives partial renames.
use super::*;
use crate::rename::{MoveFs, RealFs};
use crate::tests_xmp::same_path_or_file;
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
@@ -16,7 +17,7 @@ impl MoveFs for Faults {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.0.iter().any(|p| p == a) {
if self.0.iter().any(|p| same_path_or_file(p, a)) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move"))
} else {
RealFs.rename_no_replace(a, b)
@@ -1,6 +1,7 @@
//! Real Session/serializer regressions for saves that overlap retained physical repairs.
use super::*;
use crate::rename::{MoveFs, RealFs};
use crate::tests_xmp::same_path_or_file;
use lightcraft_catalog::{Op, Photo, Source};
use serde_json::json;
@@ -19,14 +20,14 @@ impl MoveFs for PartialRename {
RealFs.same_file(a, b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if a == self.sidecar || a == self.photo {
if same_path_or_file(a, &self.sidecar) || same_path_or_file(a, &self.photo) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault"))
} else {
RealFs.rename_no_replace(a, b)
}
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if a == self.sidecar {
if same_path_or_file(a, &self.sidecar) {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault"))
} else {
RealFs.copy_no_replace(a, b)
@@ -151,7 +152,9 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw
assert_eq!(parsed.title.as_deref(), Some("Final title"));
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
if upper {
assert!(!preferred.exists(), "merge uses the repaired uppercase packet");
// by listing: on a case-insensitive volume `preferred` answers for the upper-case packet
let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
assert!(!listed, "merge uses the repaired uppercase packet");
}
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
s.execute(step, &json!({})).unwrap();
+241 -15
View File
@@ -55,6 +55,28 @@ const HEADER_MAX: u64 = 4096;
/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve.
const LOOK_FIELD: &str = "look_version";
/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's
/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame
/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong
/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to
/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size
/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only
/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test
/// below; drop this to 95 for ~1.4× the size and roughly half the gain.)
const JPEG_QUALITY: u8 = 97;
/// The header field that records which version of this module's own encoding wrote a proxy's pixels
/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which
/// tracks the camera-look fit that is baked into those pixels.
const ENC_FIELD: &str = "enc";
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG
/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which
/// makes every proxy from before this version stale. Version 2 is the first to record the field at
/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]).
/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal.
pub(crate) const ENCODE_VERSION: u64 = 2;
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
// scale so all but the brightest 0.05 % fit into 0..1
@@ -78,13 +100,19 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
let rgba = Rgba8 { width: img.width, height: img.height, data };
let jpg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
92,
JPEG_QUALITY,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.map_err(|e| e.to_string())?;
let mut out = MAGIC.to_vec();
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
let mut head = serde_json::json!({
"w": img.width,
"h": img.height,
"scale": scale,
LOOK_FIELD: crate::camera_preview::LOOK_VERSION,
ENC_FIELD: ENCODE_VERSION,
});
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
@@ -265,10 +293,17 @@ fn header_look_version(head: &serde_json::Value) -> u64 {
head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
}
/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's
/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a
/// newer build is left alone (rebuilding it here would only undo that build's work), as is one
/// that can't be read: those are [`is_valid`]'s business.
/// The encode version a header records: a missing, negative, fractional or otherwise malformed
/// value counts as 0 (older than any real version), like [`header_look_version`].
fn header_encode_version(head: &serde_json::Value) -> u64 {
head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
}
/// Whether the complete proxy at `path` is out of date for this build: written by an older
/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are
/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG
/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that
/// build's work), as is one that can't be read: those are [`is_valid`]'s business.
pub fn is_stale(path: &Path) -> bool {
use std::io::Read;
if !is_valid(path) {
@@ -283,15 +318,18 @@ pub fn is_stale(path: &Path) -> bool {
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
rest.get(..nl)
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION)
}
/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked
/// at ([`look_checked`]).
/// The marker file in a smart previews folder: the camera-look fit version and encode version its
/// proxies were last checked at ([`look_checked`]).
const LOOK_MARKER: &str = "look-version";
/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's
/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not.
/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's
/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts
/// as not, and so does one written before the encode version was recorded — it only says the look
/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`]
/// finds.
pub fn look_checked(dir: &Path) -> bool {
use std::io::Read;
let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false };
@@ -299,14 +337,20 @@ pub fn look_checked(dir: &Path) -> bool {
if f.take(64).read_to_string(&mut text).is_err() {
return false;
}
text.trim().parse::<u64>().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION))
let mut versions = text.split_whitespace();
let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false };
match (look.parse::<u64>(), enc.parse::<u64>()) {
(Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION,
_ => false,
}
}
/// Record that the proxies in `dir` were checked at this build's camera-look fit version.
/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode
/// version.
pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
let path = dir.join(LOOK_MARKER);
lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))
let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION);
lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
}
/// Load the proxy at `path`.
@@ -451,4 +495,186 @@ mod tests {
assert!(mean < 0.02, "mean encoded error {mean}");
assert!(decode(b"nope").is_err());
}
/// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into
/// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never
/// enters what is measured. Built from a rendered demo scene so the dimensions and the flat
/// row-major layout are the real ones.
fn smooth_scene(w: usize, h: usize) -> Rgb32f {
let mut img = lightcraft_scenes::demo_library()[0].render(w, h);
let (w, h) = (img.width.max(1), img.height.max(1));
for (i, px) in img.data.iter_mut().enumerate() {
let u = (i % w) as f32 / (w - 1).max(1) as f32;
let v = (i / w) as f32 / (h - 1).max(1) as f32;
let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step
let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v];
let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)];
*px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0));
}
img
}
/// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The
/// input is clamped into 0..1, the only range a proxy ever stores.
fn lab(c: [f32; 3]) -> [f64; 3] {
let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64);
let (x, y, z) = (
(0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047,
0.2126 * r + 0.7152 * g + 0.0722 * b,
(0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883,
);
let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 };
let (fx, fy, fz) = (f(x), f(y), f(z));
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
/// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a
/// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders.
fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
let n = a.data.len().min(b.data.len());
if n == 0 {
return 0.0;
}
(0..n)
.map(|i| {
let (p, q) = (lab(a.data[i]), lab(b.data[i]));
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
})
.sum::<f64>()
/ n as f64
}
/// A deliberately frozen copy of the pixel mapping and container this module used before the
/// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the
/// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on
/// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the
/// quality raise bought instead of trusting a written-down number.
fn encode_at_q92(img: &Rgb32f) -> Vec<u8> {
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
let scale = if lum.is_empty() {
1.0
} else {
let k = ((lum.len() as f64) * 0.9995) as usize;
let k = k.min(lum.len() - 1);
let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp);
v.max(1.0)
};
let data: Vec<[u8; 4]> = img
.data
.iter()
.map(|c| {
let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8;
[e(c[0]), e(c[1]), e(c[2]), 255]
})
.collect();
let rgba = Rgba8 { width: img.width, height: img.height, data };
let jpg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
92,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.unwrap();
// the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field
let mut out = MAGIC.to_vec();
let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
out.extend_from_slice(head.to_string().as_bytes());
out.push(b'\n');
out.extend_from_slice(&jpg);
out
}
/// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one
/// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its
/// mean, and rebuilds the whole block from it, so a proxy differs from the original by a
/// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels
/// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share
/// eightfold, which is what a per-pixel ΔE drowns in.
fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
let w = a.width.min(b.width).max(1);
let h = a.height.min(b.height).max(1);
let (mut sum, mut n) = (0.0f64, 0usize);
for by in (0..h).step_by(8) {
for bx in (0..w).step_by(8) {
let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32);
for y in by..(by + 8).min(h) {
for x in bx..(bx + 8).min(w) {
if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) {
for k in 0..3 {
sa[k] += f64::from(ca[k]);
sb[k] += f64::from(cb[k]);
}
count += 1;
}
}
}
let d = f64::from(count.max(1));
let (pa, pb) = (
lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]),
lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]),
);
sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt();
n += 1;
}
}
sum / n.max(1) as f64
}
/// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit
/// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene,
/// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the
/// quality raise is reverted and never trips on the honest result (measured 0.683).
const PROXY_BLOCK_GAIN: f64 = 0.8;
#[test]
fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() {
let img = smooth_scene(256, 160);
let ours = decode(&encode(&img, None).unwrap()).unwrap().0;
let before = decode(&encode_at_q92(&img)).unwrap().0;
let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before));
let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before));
println!(
"smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \
(per-pixel {:.3} vs {:.3})",
block.0, block.1, pixel.0, pixel.1
);
assert!(block.1 > 0.0, "the old encoder must measure something to compare against");
assert!(
block.0 <= PROXY_BLOCK_GAIN * block.1,
"q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \
(the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)",
block.0,
PROXY_BLOCK_GAIN * block.1,
block.1
);
}
#[test]
fn a_proxy_from_an_older_encode_is_stale() {
let dir = temp("encode-version");
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
let bytes = encode(&img, None).unwrap();
let path = dir.join("p.lcsp");
std::fs::write(&path, &bytes).unwrap();
assert!(!is_stale(&path), "this build's own proxy is up to date");
// one written before the encode version was recorded is not
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION));
let mut older = head.as_object().cloned().unwrap_or_default();
older.remove(ENC_FIELD);
let mut old = MAGIC.to_vec();
old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes());
old.extend_from_slice(&bytes[nl..]);
std::fs::write(&path, &old).unwrap();
assert!(is_valid(&path), "losing the field keeps the file readable");
assert!(is_stale(&path), "an older encoding is rebuilt");
// the marker records both versions, and one from before the encode version forces a scan
assert!(!look_checked(&dir));
mark_look_checked(&dir).unwrap();
assert!(look_checked(&dir));
std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap();
assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild");
let _ = std::fs::remove_dir_all(&dir);
}
}
+15
View File
@@ -1163,3 +1163,18 @@ fn undo_and_redo_show_the_photo_they_change() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.selection, before, "a step over several photos keeps the selection");
}
#[test]
fn hdr_edits_render_with_their_hdr_histogram() {
let mut s = demo();
let id = s.active().unwrap();
let plain = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
assert!(plain.histogram.hdr.is_none(), "SDR edits: the image's histogram");
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
let r = s.loupe_job(id, 200, 200, true).unwrap().run().rendered.unwrap();
let bins = r.histogram.hdr.expect("an HDR edit's histogram");
assert_eq!(bins.stops, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32, "a fixed scale");
assert!(r.histogram.above_sdr() > 0.0);
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
}
+20 -2
View File
@@ -81,11 +81,11 @@ fn quick_develop_reset_and_batch_paths_fit_too() {
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
// back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388)
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
let g = geometry(&s);
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0));
assert!(g.is_within_image(w, h));
}
@@ -106,3 +106,21 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(geometry(&s), start));
}
#[test]
fn a_crop_shrunk_by_straightening_grows_back_towards_0() {
// issue #1388: slide the Straighten slider out and back, in one drag or in several
let mut s = Session::with_demo();
let (w, h) = dims(&s);
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] {
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
let g = geometry(&s);
assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap();
s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap();
let g = geometry(&s);
assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}");
}
+23
View File
@@ -277,6 +277,29 @@ fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() {
let _ = std::fs::remove_dir_all(&x.dir);
}
/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then
/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described.
#[test]
fn inspecting_another_apps_model_folder_says_what_is_missing() {
let dir = temp_dir("denoise-foreign");
let mut s = Session::new().with_fs();
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
let src = dir.join("rawdenoise");
std::fs::create_dir_all(&src).unwrap();
let onnx = src.join("rawdenoise.onnx");
std::fs::write(&onnx, b"not an onnx file").unwrap();
let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
let e = inspect();
assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}");
std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap();
let e = inspect();
assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}");
std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap();
let e = inspect();
assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() {
let mut x = setup("background", true);
+332
View File
@@ -455,6 +455,21 @@ fn hdr_avif_export_is_pq_rec2020() {
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
}
#[test]
fn hdr_avif_export_hlg() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70}));
assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg);
assert_eq!(o.to_json()["hdrTransfer"], "hlg");
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
// nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box
let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box");
assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]);
assert!(!hdr.bytes.windows(4).any(|w| w == b"clli"));
}
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
#[test]
@@ -542,3 +557,320 @@ fn hdr_png_export_is_16_bit_pq_rec2020() {
let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap();
assert!(!plain.bytes.windows(4).any(|w| w == b"cICP"));
}
#[test]
fn hdr_jxl_export_is_pq_rec2020() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "jxl", "hdr": true, "quality": 90}));
assert!(o.hdr_output());
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
let encoding = |b: &[u8]| {
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(b)).unwrap();
let m = &img.image_header().metadata;
(format!("{:?}", m.colour_encoding), m.bit_depth.bits_per_sample())
};
let (c, bits) = encoding(&hdr.bytes);
assert!(c.contains("Bt2100") && c.contains("Pq"), "{c}");
assert_eq!(bits, 16);
// a photo without an HDR edit exports as an ordinary SDR file
let (c, _) = encoding(&sdr.bytes);
assert!(!c.contains("Pq"), "{c}");
}
#[test]
fn jxl_export_writes_metadata() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 120, "format": "jxl", "quality": 80}));
let e = export_photo(&mut s, id, &o, 1).unwrap();
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
assert_eq!(d.width.max(d.height), 120);
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("Golden horizon")), "title in the XMP box");
assert!(d.exif.is_some(), "EXIF box");
}
/// Issue #236: Add to Apple Photos after an export. A fake runner stands in for osascript: tests
/// never start it, nor Photos.
#[cfg(not(target_arch = "wasm32"))]
mod apple_photos {
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use super::disk_batch;
use crate::Session;
use crate::apple_photos::{Imports, MARKER, Output, Runner};
use crate::export::{AfterExport, Destination, ExportOptions};
use crate::tests_xmp::temp_dir;
type Calls = Arc<Mutex<Vec<Vec<String>>>>;
/// Records the arguments; Photos "imports" the first `keep` files (all: `usize::MAX`).
fn fake(keep: usize) -> (Runner, Calls) {
let calls: Calls = Arc::default();
let c = calls.clone();
let r: Runner = Arc::new(move |args: &[String], _| {
c.lock().unwrap().push(args.to_vec());
let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
let ids: Vec<String> = (0..files.min(keep)).map(|i| format!("ID-{i}/L0/001")).collect();
Ok(Output { status: Some(0), stdout: ids.join("\n") + "\n", stderr: String::new() })
});
(r, calls)
}
/// The paths after the album: the files Photos was asked to import.
fn imported_paths(args: &[String]) -> Vec<String> {
args[args.iter().position(|a| a == MARKER).unwrap() + 2..].to_vec()
}
fn export_two(s: &mut Session, tag: &str) -> Vec<Value> {
let dir = temp_dir(tag);
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
let o = ExportOptions::from_json(&json!({"longEdge": 48, "naming": "{name}-{seq}"}));
let to = Destination { dir: dir.to_string_lossy().into(), exact: None };
let mut files = Vec::new();
for id in ids {
files.extend(disk_batch(s, id, &o, &to).unwrap());
}
files
}
#[test]
fn exported_files_go_to_photos_in_order() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-flow");
let written: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
assert_eq!(written.len(), 2);
let (runner, calls) = fake(usize::MAX);
let imports = Imports::default();
let after = AfterExport::apple_photos(runner.clone(), " Trip \"2026\"\n ", &imports).unwrap();
// a skipped file and a failed photo are not sent
let mut results = files.clone();
results.insert(1, json!({"skipped": "/elsewhere/x.jpg"}));
results.push(json!({"photo": 9, "file": "y", "error": "no source"}));
let out = after.run(&results, false).unwrap();
assert_eq!(out["imported"], 2, "{out}");
assert_eq!(out["requested"], 2);
assert_eq!(out["ids"], json!(["ID-0/L0/001", "ID-1/L0/001"]));
assert_eq!(out["album"], "Trip \"2026\"");
assert!(out.get("warning").is_none());
let args = calls.lock().unwrap()[0].clone();
assert_eq!(imported_paths(&args), written, "the files written, in order, as whole arguments");
assert!(written.iter().all(|p| std::path::Path::new(p).is_absolute()));
assert_eq!(args[args.iter().position(|a| a == MARKER).unwrap() + 1], "Trip \"2026\"");
// a cancelled export adds nothing
let out = AfterExport::apple_photos(runner.clone(), "", &imports).unwrap().run(&files, true).unwrap();
assert!(out["skipped"].as_str().unwrap().contains("cancelled"));
assert_eq!(calls.lock().unwrap().len(), 1);
// Photos took one of the two: said so, not an error
let (runner, _) = fake(1);
let out = AfterExport::apple_photos(runner, "", &Imports::default()).unwrap().run(&files, false).unwrap();
assert_eq!((out["imported"].as_u64(), out["requested"].as_u64()), (Some(1), Some(2)));
assert!(out["warning"].as_str().unwrap().contains("1 of 2"), "{out}");
assert!(out["album"].is_null());
// permission refused: the outcome says where to allow it; the files stay written
let denied: Runner = Arc::new(|_: &[String], _| {
Ok(Output {
status: Some(1),
stdout: String::new(),
stderr: "execution error: Not authorized to send Apple events to Photos. (-1743)".into(),
})
});
let out = AfterExport::apple_photos(denied, "", &Imports::default()).unwrap().run(&files, false).unwrap();
assert!(out["error"].as_str().unwrap().contains("Privacy & Security › Automation"), "{out}");
assert!(written.iter().all(|p| std::path::Path::new(p).is_file()));
// nothing asked: nothing done
assert!(AfterExport::default().is_none());
assert_eq!(AfterExport::default().run(&files, false), None);
}
#[test]
fn photos_is_refused_before_anything_is_written_when_it_cannot_be_reached() {
let mut s = Session::with_demo();
assert!(s.apple_photos.is_none(), "only with_fs installs the runner");
let on = ExportOptions { add_to_photos: true, ..Default::default() };
let e = s.after_export(&on).err().unwrap();
if cfg!(target_os = "macos") {
assert!(e.contains("isn't connected in this session"), "{e}");
} else {
assert_eq!(e, "Apple Photos is only available on macOS");
}
assert!(s.after_export(&ExportOptions::default()).unwrap().is_none());
// with a runner: macOS adds, other platforms still refuse
let (runner, calls) = fake(usize::MAX);
s.apple_photos = Some(runner);
let after = s.after_export(&on);
assert_eq!(after.is_ok(), cfg!(target_os = "macos"), "{:?}", after.as_ref().err());
match after {
Ok(after) => {
let files = export_two(&mut s, "photos-session");
assert_eq!(after.run(&files, false).unwrap()["imported"], 2);
assert_eq!(calls.lock().unwrap().len(), 1);
}
Err(e) => assert!(e.contains("only available on macOS"), "{e}"),
}
}
#[test]
fn the_add_to_photos_command_needs_a_runner_and_checks_its_input() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-cmd");
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
let info = |s: &Session| s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap();
// no runner: listed, disabled, with the reason
let i = info(&s);
assert!(!i.enabled);
let why = i.disabled_reason.unwrap();
assert!(why.contains(if cfg!(target_os = "macos") { "isn't connected" } else { "only available on macOS" }), "{why}");
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
assert!(e.contains("Apple Photos"), "{e}");
let (runner, calls) = fake(usize::MAX);
s.apple_photos = Some(runner);
assert!(info(&s).enabled);
// no window: it waits for Photos by default
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Läufe/日本 \\ \"q\""})).unwrap();
assert_eq!((r["imported"].as_u64(), r["running"].as_bool(), r["job"].as_u64()), (Some(2), Some(false), Some(1)), "{r}");
assert_eq!(r["album"], "Läufe/日本 \\ \"q\"");
assert_eq!(imported_paths(&calls.lock().unwrap()[0]), paths);
// bad input never reaches the runner (paths from the temp folder: absolute on every platform)
let missing = std::env::temp_dir().join("lc-photos-nonexistent").join("a.jpg").to_string_lossy().into_owned();
for (p, msg) in [
(json!({}), "missing `paths`"),
(json!({"paths": "a.jpg"}), "missing `paths`"),
(json!({"paths": [1]}), "array of file paths"),
(json!({"paths": []}), "no files"),
(json!({"paths": ["relative.jpg"]}), "absolute"),
(json!({"paths": [missing]}), "no such file"),
(json!({"paths": paths, "album": 3}), "`album` must be a string"),
(json!({"paths": paths, "wait": "yes"}), "`wait` must be true or false"),
(json!({"paths": paths, "albm": "x"}), "unknown parameter `albm`"),
] {
let e = s.execute("export.addToPhotos", &p).unwrap_err().to_string();
assert!(e.contains(msg), "{p}: {e}");
}
assert_eq!(calls.lock().unwrap().len(), 1);
// not journaled: replaying actions must not import again
assert!(!s.journal.iter().any(|(id, _)| id == "export.addToPhotos"));
}
/// The desktop app answers control requests on its UI thread: `export.addToPhotos` must not
/// wait there for Photos (minutes, or a permission prompt). It returns the running job; the
/// outcome comes from `export.photosImports`; a second import, or an export asking for Photos,
/// is refused until it ends, so a retry never adds the files twice.
#[test]
fn a_slow_photos_import_runs_in_the_background() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-slow");
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
let (go, wait) = std::sync::mpsc::channel::<()>();
let wait = Mutex::new(wait);
let runner: Runner = Arc::new(move |args: &[String], _| {
// a regression (waiting on the caller's thread) fails after 20 s instead of hanging
let _ = wait.lock().unwrap().recv_timeout(std::time::Duration::from_secs(20));
let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
Ok(Output { status: Some(0), stdout: (0..n).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() })
});
s.apple_photos = Some(runner);
s.apple_photos_background = true; // as in the desktop app
let t0 = std::time::Instant::now();
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Slow"})).unwrap();
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "answered without waiting for Photos");
assert_eq!(r, json!({"job": 1, "running": true, "requested": 2, "album": "Slow"}));
let status = |s: &mut Session| s.execute("export.photosImports", &json!({"job": 1})).unwrap();
assert_eq!(status(&mut s)["running"], true);
// a retry is refused, and says why
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}");
if cfg!(target_os = "macos") {
let on = ExportOptions { add_to_photos: true, ..Default::default() };
assert!(s.after_export(&on).err().unwrap().contains("still adding"), "refused before writing anything");
}
go.send(()).unwrap();
while status(&mut s)["running"] == true {
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the import finishes");
std::thread::sleep(std::time::Duration::from_millis(5));
}
let done = status(&mut s);
assert_eq!((done["imported"].as_u64(), done["album"].as_str()), (Some(2), Some("Slow")), "{done}");
let all = s.execute("export.photosImports", &json!({})).unwrap();
assert_eq!(all["imports"].as_array().map(Vec::len), Some(1));
assert!(s.execute("export.photosImports", &json!({"job": 7})).unwrap_err().to_string().contains("no import 7"));
// the window's thread never waits for Photos: `wait: true` is refused here, before any import
let e = s.execute("export.addToPhotos", &json!({"paths": paths, "wait": true})).unwrap_err().to_string();
assert!(e.contains("isn't available in the desktop app"), "{e}");
// finished: the next one may start
go.send(()).unwrap();
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["job"], 2);
}
/// Review of #236: an export adding to Photos reserves the import before it writes its files.
/// While it renders, `export.addToPhotos` is refused, naming the export's job, instead of
/// taking Photos and leaving the export to fail its Photos step after writing.
#[test]
fn an_export_holds_photos_from_before_it_writes() {
let mut s = Session::with_demo();
let (runner, calls) = fake(usize::MAX);
s.apple_photos = Some(runner.clone());
let elsewhere = export_two(&mut s, "photos-hold-other");
let other: Vec<String> = elsewhere.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
// what Session::after_export does on macOS (it refuses other platforms first)
let after = AfterExport::apple_photos(runner, "Held", &s.apple_photos_imports).unwrap();
let job = s.execute("export.photosImports", &json!({"job": 1})).unwrap();
assert_eq!((job["running"].as_bool(), job["exporting"].as_bool()), (Some(true), Some(true)), "{job}");
let e = s.execute("export.addToPhotos", &json!({"paths": other})).unwrap_err().to_string();
assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}");
if cfg!(target_os = "macos") {
let on = ExportOptions { add_to_photos: true, ..Default::default() };
assert!(s.after_export(&on).err().unwrap().contains("(import 1)"), "a second export is refused before writing");
}
assert!(calls.lock().unwrap().is_empty());
// the export writes its files and hands them over: imported under its own job
let files = export_two(&mut s, "photos-hold");
let out = after.run(&files, false).unwrap();
assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out["album"].as_str()), (Some(1), Some(2), Some("Held")), "{out}");
// then the slot is free
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 2);
// an export that fails (its AfterExport dropped) frees it too
drop(AfterExport::apple_photos(fake(usize::MAX).0, "", &s.apple_photos_imports).unwrap());
assert!(s.apple_photos_imports.running().is_none());
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 4);
}
/// Review of #236: a runner that panics while the command or an export waits for it is that
/// import's error; the slot is freed, so the next import isn't refused as "still running"
/// and a process ending doesn't wait for it.
#[test]
fn a_panicking_runner_never_holds_photos() {
let mut s = Session::with_demo();
let files = export_two(&mut s, "photos-panic");
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
s.apple_photos = Some(Arc::new(|_: &[String], _| panic!("runner exploded")));
// the command (no window: it waits)
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
assert!(e.contains("stopped unexpectedly"), "{e}");
assert!(s.apple_photos_imports.running().is_none());
assert_eq!(s.execute("export.photosImports", &json!({"job": 1})).unwrap()["running"], false);
// an export's reservation
let runner = s.apple_photos.clone().unwrap();
let out = AfterExport::apple_photos(runner, "", &s.apple_photos_imports).unwrap().run(&files, false).unwrap();
assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}");
assert!(s.apple_photos_imports.running().is_none());
assert!(!s.wait_for_photos_imports(), "nothing left to wait for");
// and the next import runs
let (good, _) = fake(usize::MAX);
s.apple_photos = Some(good);
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["imported"], 2);
}
/// The desktop app, lightcraft-cli and the MCP server get the real runner from `with_fs`, on
/// macOS only. (Installed, never run here.)
#[test]
fn with_fs_installs_the_runner_on_macos_only() {
let s = Session::new().with_fs();
assert_eq!(s.apple_photos.is_some(), cfg!(target_os = "macos"));
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
}
}
+22 -11
View File
@@ -31,11 +31,21 @@ fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool
})
}
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
fn slash(path: &str) -> String {
path.replace('\\', "/")
}
fn file_source(s: &Session, id: u64) -> String {
let Source::File { path } = &s.catalog.photo(PhotoId(id)).unwrap().source else { panic!("synthetic file fixture") };
slash(path)
}
fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) {
let files = fs.listing();
for photo in s.catalog.photos() {
let Source::File { path } = &photo.source else { panic!("synthetic file fixture") };
assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0);
assert!(files.iter().any(|(file, _)| *file == slash(path)), "photo {} points to missing {path}: {files:?}", photo.id.0);
assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str());
}
let replayed = memory_library(store);
@@ -102,7 +112,8 @@ fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal()
let renamed = fs.listing();
s.merge_undo(2, "Rename Twice");
assert_eq!(s.undo.len(), 1);
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]);
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
assert_eq!(moves, vec![("/p/final.jpg".to_string(), "/p/a.jpg".to_string())]);
// First exercise the ordinary merged step, before any corrective paths exist.
for redo in [false, true] {
file_history_step(&mut s, &fs, redo).unwrap();
@@ -274,7 +285,7 @@ fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollb
file_history_step(&mut s, &fs, redo).unwrap();
let mut after = before
.iter()
.map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone()))
.map(|(path, bytes)| (path.replace(slash(&from).trim_end_matches(".jpg"), slash(&to).trim_end_matches(".jpg")), bytes.clone()))
.collect::<Vec<_>>();
after.sort();
assert_eq!(fs.listing(), after);
@@ -343,7 +354,7 @@ fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() {
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()];
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() });
assert_eq!(file_source(&s, 1), "/p/Trip.jpg");
assert_eq!(s.undo.len(), 1);
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2);
assert_file_catalog_and_journal(&s, &fs, &store);
@@ -513,10 +524,11 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
);
s.merge_undo(3, "Rename Three Times");
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]);
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
assert_eq!(moves, vec![("/p/d.jpg".to_string(), "/p/b.jpg".to_string()), ("/p/c.jpg".to_string(), "/p/a.jpg".to_string())]);
// The second photo move sticks; the preceding d -> b move must roll back to d.
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()];
let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string();
let error = slash(&file_history_step(&mut s, &fs, false).unwrap_err().to_string());
assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}");
assert!(error.contains("/p/c.jpg could not be moved back"), "{error}");
let files = fs.listing();
@@ -529,9 +541,8 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
]
);
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
let photo2 = s.catalog.photo(PhotoId(2)).unwrap();
assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg");
let Source::File { path } = &photo2.source else { panic!("file fixture") };
assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
assert_eq!(file_source(&s, 1), "/p/a.jpg");
let path = file_source(&s, 2);
assert_eq!(path, "/p/d.jpg", "photo 2 rolled back physically and must still identify d.jpg");
assert_eq!(files.iter().find(|(file, _)| *file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
}
+2 -1
View File
@@ -40,7 +40,8 @@ impl Scratch {
Scratch(dir)
}
fn path(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().to_string()
// the host's separator throughout, as the code under test spells joined paths
self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string()
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a
//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent.
use serde_json::json;
use crate::Session;
fn exposure(s: &Session) -> f64 {
s.develop_of(s.active().unwrap()).unwrap().light.exposure
}
fn list(s: &mut Session) -> (usize, Option<usize>) {
let h = s.execute("history.list", &json!({})).unwrap();
(h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize))
}
/// A demo session with three exposure edits (1, 2, 3) as its latest History steps.
fn three_edits() -> (Session, usize) {
let mut s = Session::with_demo();
for v in [1.0, 2.0, 3.0] {
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
}
let (n, cursor) = list(&mut s);
assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step");
(s, n)
}
#[test]
fn going_back_adds_no_step_and_forward_returns() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
assert_eq!(exposure(&s), 1.0);
assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept");
s.execute("history.next", &json!({})).unwrap();
assert_eq!(exposure(&s), 2.0);
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
assert_eq!(exposure(&s), 3.0);
assert_eq!(list(&mut s), (n, Some(n - 1)));
assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step");
s.execute("history.previous", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2))));
assert!(s.execute("history.restore", &json!({"index": n})).is_err());
assert!(s.execute("history.restore", &json!({"index": -1})).is_err());
}
#[test]
fn a_new_edit_behind_the_end_drops_the_later_steps() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended");
assert_eq!(exposure(&s), -1.0);
// undo brings the dropped steps and the cursor back
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2))));
}
#[test]
fn undo_and_redo_move_the_cursor_with_the_settings() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
}
#[test]
fn clearing_history_resets_the_cursor() {
let (mut s, n) = three_edits();
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
s.execute("history.clear", &json!({})).unwrap();
assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0))));
assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None);
}
#[test]
fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() {
let mut s = Session::with_demo();
let snap = s.catalog.to_snapshot();
let _ = s.drain_log();
for v in [1.0, 2.0] {
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
}
let (n, _) = list(&mut s);
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap();
c.replay(&log).unwrap();
assert_eq!(c.to_snapshot(), s.catalog.to_snapshot());
let id = s.active().unwrap();
assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2));
// a photo written before the cursor existed has no `history_cursor` key: at the latest step
let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap();
v.as_object_mut().unwrap().remove("history_cursor");
let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap();
assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1)));
}
+48 -2
View File
@@ -21,6 +21,9 @@ fn write_png(path: &Path, seed: u8) {
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}
fn ids(v: &Value, key: &str) -> usize {
@@ -309,6 +312,27 @@ fn recently_added_covers_recent_imports_newest_first() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recently_added_keeps_an_import_batch_in_file_order() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let mut s = Session::new();
// one import batch: one `imported` string, ids running opposite to the names
let mut by_name = Vec::new();
for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() {
let id = PhotoId(5000 - n as u64);
let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
by_name.push(name.to_string());
}
s.clock = Box::new(|| "2026-10-01T09:00:00".to_string());
for ascending in [true, false] {
s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap();
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
assert_eq!(names, by_name, "ascending: {ascending}");
}
}
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
/// import order), and a metadata preset on every photo.
#[test]
@@ -395,11 +419,17 @@ fn auto_import_watched_folder() {
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "album": "Tethered"})).unwrap();
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
write_png(&dir.join("one.png"), 1);
std::fs::write(
dir.join("one.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 xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:CameraProfile="Color" crs:Exposure2012="0.5"/></rdf:RDF></x:xmpmeta>"#,
)
.unwrap();
std::fs::write(dir.join("notes.txt"), "not a photo").unwrap();
// first sight: wait (it may still be copying)
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
let r = s.execute("library.autoImportScan", &json!({})).unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Color"), "{r}");
let album = s.catalog.albums().find(|a| a.name == "Tethered").expect("album").id;
assert_eq!(s.catalog.album_count(album), 1);
assert!(s.selection.ids.is_empty(), "arrivals don't take the selection");
@@ -554,6 +584,14 @@ fn cube_luts_become_profiles() {
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
s.execute("profile.favorite", &json!({"id": id})).unwrap();
assert!(s.profile_favorites.contains(&id));
// `profiles.list` lists it next to the built-in profiles (#618)
let listed = |s: &mut Session| {
let list = s.execute("profiles.list", &json!({})).unwrap();
let list = list.as_array().unwrap();
assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed");
list.iter().find(|p| p["id"] == id.as_str()).cloned()
};
assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true})));
// the library keeps it
drop(s);
lightcraft_pipeline::lut::unregister(&id);
@@ -563,8 +601,10 @@ fn cube_luts_become_profiles() {
assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen");
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen");
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
assert!(lightcraft_pipeline::lut::get(&id).is_none());
assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
@@ -1398,14 +1438,20 @@ fn smart_preview_look_marker_is_read_safely() {
let dir = temp_dir("smartlook-marker");
assert!(!crate::smart::look_checked(&dir), "no marker");
let cur = crate::camera_preview::LOOK_VERSION;
let enc = crate::smart::ENCODE_VERSION;
for (text, checked) in [
(String::new(), false),
("banana".into(), false),
("-1".into(), false),
(format!("{}", cur.saturating_sub(1)), false),
("9".repeat(200), false),
(format!("{cur}\n"), true),
(format!(" {}\n", cur + 1), true),
// Written before the encode version was recorded: only says the look was checked, so the
// folder is scanned once more and the old-encoding proxies are rebuilt.
(format!("{cur}\n"), false),
(format!("{cur} {}\n", enc.saturating_sub(1)), false),
(format!("{cur} banana\n"), false),
(format!("{cur} {enc}\n"), true),
(format!(" {} {}\n", cur + 1, enc + 1), true),
] {
std::fs::write(dir.join("look-version"), &text).unwrap();
assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}");
+3
View File
@@ -22,6 +22,9 @@ fn write_png(path: &Path, seed: u8) {
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}
fn xmp(rating: u8) -> String {
+15 -6
View File
@@ -155,13 +155,22 @@ fn odd_folders_never_fail_the_check() {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe"));
// APFS (macOS) refuses names that are not valid UTF-8 (EILSEQ): there the non-UTF-8 cases are skipped and
// the symlink cases below still run on a plain-named catalog folder
let probe = root.join(std::ffi::OsStr::from_bytes(b"\xff"));
let utf8_only = std::fs::create_dir(&probe).is_err();
let _ = std::fs::remove_dir(&probe);
if !utf8_only {
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
std::fs::create_dir_all(&bad).unwrap();
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
}
let lr = root.join(if utf8_only { std::ffi::OsStr::new("lr") } else { std::ffi::OsStr::from_bytes(b"\xff\xfe") });
std::fs::create_dir_all(&lr).unwrap();
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
if !utf8_only {
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
}
std::fs::write(lr.join("b.lrcat"), b"x").unwrap();
assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped");
// a symlink to a catalog folder is one too; a dangling one is not
+25
View File
@@ -67,6 +67,31 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
#[test]
fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() {
let dir = temp_dir("hdr-colour");
let neutral = [0.5, 1.0, 0.7];
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() {
// the same pixels with a white balance but no colour matrices
let mut raw = lightcraft_raw::decode(&b).unwrap();
raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() };
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color);
let got = merged.color.as_shot_neutral.expect("the sources' white balance");
assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
+126
View File
@@ -0,0 +1,126 @@
//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays
//! responsive: commands that don't touch presets never re-serialize them, and opening or importing
//! doesn't compare every preset with every other.
use std::path::PathBuf;
use std::time::Instant;
use lightcraft_develop::Preset;
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn many(n: usize) -> Vec<Preset> {
(0..n)
.map(|i| Preset {
id: format!("user.coll-{}.look-{i}", i / 100),
name: format!("Look {i}"),
group: format!("Collection {}", i / 100),
settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}),
favorite: false,
builtin: false,
})
.collect()
}
#[test]
fn develop_commands_do_not_reserialize_unchanged_presets() {
let lib = temp_dir("no-reserialize");
let mut s = Session::new();
s.open_library(&lib, true).unwrap();
let added = s.add_presets(many(2000));
assert_eq!(added.len(), 2000);
s.persist().unwrap();
assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved");
let before = crate::library::presets_serialized();
for i in 0..20 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
}
assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone");
// a preset change is still saved
let pid = added[7].clone();
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
assert!(crate::library::presets_serialized() > before);
s.close_library().unwrap();
drop(s);
let mut t = Session::new();
t.open_library(&lib, false).unwrap();
assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted");
assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn preset_list_generation_follows_mutable_access_only() {
let mut s = Session::new();
let g0 = s.presets.generation();
let _ = s.presets.iter().filter(|p| p.builtin).count();
let _ = s.presets.len();
assert_eq!(s.presets.generation(), g0, "reads keep the generation");
s.presets.push(many(1).remove(0));
let g1 = s.presets.generation();
assert_ne!(g1, g0);
for p in &mut s.presets {
p.favorite = false;
}
assert_ne!(s.presets.generation(), g1);
// generations are unique across sessions (UI caches key on them alone)
let other = Session::new();
assert_ne!(other.presets.generation(), s.presets.generation());
}
#[test]
fn importing_thousands_of_presets_is_not_quadratic() {
let mut s = Session::new();
let t = Instant::now();
let added = s.add_presets(many(20_000));
assert_eq!(added.len(), 20_000);
// the same presets again are all skipped as duplicates
assert!(s.add_presets(many(20_000)).is_empty());
// a clashing id with different settings gets a fresh, unique one
let mut clash = many(1).remove(0);
clash.settings = json!({"light": {"exposure": 3.0}});
let id = s.add_presets(vec![clash]).remove(0);
assert_ne!(id, "user.coll-0.look-0");
assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1);
// quadratic was ~200 M comparisons here (tens of seconds in debug builds)
assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed());
}
/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test
/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied
/// into a temporary library; the original is only read.
#[test]
#[ignore]
fn presets_scale_bench() {
let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else {
eprintln!("set LIGHTCRAFT_BENCH_PRESETS");
return;
};
let lib = temp_dir("bench");
std::fs::copy(&src, lib.join("presets.json")).unwrap();
let mut s = Session::new();
let t = Instant::now();
s.open_library(&lib, true).unwrap();
eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len());
let t = Instant::now();
for i in 0..20 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
}
eprintln!("develop.set: {:?} per command", t.elapsed() / 20);
let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap();
let t = Instant::now();
s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap();
eprintln!("preset.apply: {:?}", t.elapsed());
let t = Instant::now();
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed());
let _ = std::fs::remove_dir_all(&lib);
}
+17
View File
@@ -4,6 +4,23 @@ use serde_json::json;
use crate::Session;
/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends.
#[test]
fn up_and_down_move_by_rows_and_stop_at_the_ends() {
let mut s = Session::with_demo();
let vis = s.visible_cloned();
assert!(vis.len() >= 6);
s.selection = crate::Selection::single(vis[0]);
s.execute("library.down", &json!({"rows": 4})).unwrap();
assert_eq!(s.selection.active, Some(vis[4]));
s.execute("library.up", &json!({})).unwrap();
assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)");
s.execute("library.up", &json!({"rows": 99})).unwrap();
assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo");
s.execute("library.down", &json!({"rows": u64::MAX})).unwrap();
assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow");
}
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
}
+254
View File
@@ -7,6 +7,26 @@ use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
use serde_json::json;
use crate::Session;
use crate::rename::{MoveFs, RealFs};
/// Whether `dir` is on a case-insensitive volume (the default on macOS and Windows): its path in upper case still resolves.
pub(crate) fn case_insensitive(dir: &Path) -> bool {
Path::new(&dir.to_string_lossy().to_uppercase()).exists()
}
/// The same path, or the same file under another spelling (`a.XMP` and `a.xmp` on a case-insensitive volume). Fault
/// injectors match this way, so an injected failure fires whichever spelling the code under test uses for a file; on a
/// case-sensitive volume only identical paths match.
pub(crate) fn same_path_or_file(a: &Path, b: &Path) -> bool {
a == b || RealFs.same_file(a, b)
}
/// The same path, or on a case-insensitive volume a path that differs only in ASCII case: the code under test may spell
/// a sidecar `.XMP` for a file stored as `.xmp` there, while on a case-sensitive volume it offers only the spelling that
/// exists.
pub(crate) fn same_path_for_volume(a: &Path, b: &Path) -> bool {
a == b || (a.parent().is_some_and(case_insensitive) && a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy()))
}
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id()));
@@ -171,6 +191,201 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
std::fs::remove_file(src.join("harbour.xmp")).unwrap();
let r = s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
assert!(r.get("warnings").is_none(), "{r}");
let _ = std::fs::remove_dir_all(&src);
}
/// A sidecar with only `crs:` develop settings, as LRTimelapse writes them (issue #934).
fn crs_exposure_sidecar(exposure: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="15.0" crs:Exposure2012="{exposure}"/></rdf:RDF></x:xmpmeta>"#
)
}
/// Issue #934: with auto-write on, Read Metadata from File wrote what it had just read straight back
/// into the sidecar, adding our `lc:settings`. Reading leaves the file byte for byte; an edit made
/// after it is still written.
#[test]
fn reading_metadata_leaves_the_sidecar_alone_with_auto_write_on() {
let src = temp_dir("read-no-autowrite");
write_png(&src.join("frame.png"), 6);
let sidecar = src.join("frame.xmp");
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = only(&s);
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
// something for the read to change back (auto-write is still off, so the file stays as it is)
s.execute("develop.reset", &json!({"ids": [id.0]})).unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
let before = std::fs::read(&sidecar).unwrap();
let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5, "the sidecar was read");
let now = std::fs::read(&sidecar).unwrap();
assert!(now == before, "reading leaves the sidecar byte for byte, but it is now:\n{}", String::from_utf8_lossy(&now));
// a develop edit afterwards is still written (only the read is exempt)
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.9})).unwrap();
let after = std::fs::read_to_string(&sidecar).unwrap();
match crate::sidecar::parse_sidecar(&after, crate::crs::Target::Rendered).unwrap().develop {
Some(crate::sidecar::DevelopPatch::Full(d)) => assert_eq!(d.light.exposure, 0.9),
other => panic!("the edit wasn't auto-written: {other:?}\n{after}"),
}
assert!(after.contains(r#"crs:Exposure2012="0.5""#), "the other app's settings are kept: {after}");
let _ = std::fs::remove_dir_all(&src);
}
/// Issue #934, the round trip the write-back broke: after a read with auto-write on, another app
/// changes `crs:Exposure2012` from 0.5 to 1.2 (keeping the rest of the file), and the next read
/// takes 1.2.
#[test]
fn a_crs_change_after_a_read_is_taken_with_auto_write_on() {
let src = temp_dir("read-then-crs-change");
write_png(&src.join("frame.png"), 7);
let sidecar = src.join("frame.xmp");
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = only(&s);
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
// the other app edits the file as it is now
let text = std::fs::read_to_string(&sidecar).unwrap();
let changed = text.replace(r#"crs:Exposure2012="0.5""#, r#"crs:Exposure2012="1.2""#);
assert_ne!(changed, text);
std::fs::write(&sidecar, changed).unwrap();
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 1.2, "the second read takes the other app's exposure");
let _ = std::fs::remove_dir_all(&src);
}
/// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar
/// and our own starting look stands in for them: importing one gives no camera-profile warning.
#[test]
fn default_lightroom_profiles_are_not_reported() {
let src = temp_dir("xmp-default-profile");
for (i, name) in ["Adobe Standard", "Adobe Color"].into_iter().enumerate() {
let photo = src.join(format!("p{i}.png"));
write_png(&photo, 6);
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:CameraProfile="{name}" crs:Exposure2012="0.5"/>
</rdf:RDF></x:xmpmeta>"#
);
std::fs::write(src.join(format!("p{i}.xmp")), xmp).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap();
let none = Vec::new();
let warnings = r["warnings"].as_array().unwrap_or(&none);
assert!(!warnings.iter().any(|w| w["code"] == "cameraProfile"), "{name}: {r}");
assert_eq!(s.catalog.photos().next().unwrap().develop.light.exposure, 0.5, "{name}: the edits still import");
}
let _ = std::fs::remove_dir_all(&src);
}
/// Foreign profile names and saved Upright matrices stay visible without pretending their
/// proprietary colour or coordinate data was imported. Diagnostics are per-photo report data;
/// importing a file without XMP remains quiet, and the source packet is never rewritten.
#[test]
fn foreign_profile_and_upright_import_diagnostics_are_actionable() {
let src = temp_dir("xmp-diagnostics");
let photo = src.join("faithful.png");
write_png(&photo, 6);
let 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:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:CameraProfile="Camera Faithful" crs:Exposure2012="1.68" crs:PerspectiveUpright="1"
crs:UprightVersion="151388160" crs:UprightTransformCount="6"
crs:UprightCenterNormX="0.554156" crs:UprightCenterNormY="0.529745"
crs:UprightTransform_1="0.897233402,-0.013204641,-0.030578613,-0.096087436,0.953621255,0.003270197,-0.157075841,-0.043520282,1.000000000"/>
</rdf:RDF></x:xmpmeta>"#;
let sidecar = src.join("faithful.xmp");
std::fs::write(&sidecar, xmp).unwrap();
let before = std::fs::read(&sidecar).unwrap();
let photo_path = photo.to_string_lossy().to_string();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap();
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Camera Faithful"), "{r}");
assert!(warnings.iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "auto"), "{r}");
assert!(warnings.iter().all(|w| w.get("fields").is_none()), "{r}");
let import_warnings: Vec<crate::import::ImportWarning> =
warnings.iter().cloned().map(serde_json::from_value).collect::<std::result::Result<_, _>>().unwrap();
let import_profile = import_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::CameraProfile).unwrap();
assert_eq!(import_profile.path, photo_path);
assert_eq!(import_profile.profile.as_deref(), Some("Camera Faithful"));
assert!(import_profile.fields.is_empty());
let import_upright = import_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::UprightGeometry).unwrap();
assert_eq!(import_upright.path, photo_path);
assert_eq!(import_upright.mode, Some(crate::import::ImportUprightMode::Auto));
assert!(import_upright.fields.is_empty());
let p = s.catalog.photos().next().unwrap().clone();
assert_eq!(p.develop.profile.id, "lc.color");
assert_eq!(p.develop.light.exposure, 1.68);
assert_eq!(std::fs::read(&sidecar).unwrap(), before, "import never rewrites foreign XMP");
// Metadata reread carries same structured diagnostics, with each saved Upright mode typed.
let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap();
let reread_wires = reread["warnings"].as_array().unwrap();
assert!(reread_wires.iter().all(|w| w.get("fields").is_none()), "{reread}");
let reread_warnings: Vec<crate::import::ImportWarning> =
reread_wires.iter().cloned().map(serde_json::from_value).collect::<std::result::Result<_, _>>().unwrap();
let reread_profile = reread_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::CameraProfile).unwrap();
assert_eq!(reread_profile.path, photo_path);
assert_eq!(reread_profile.profile.as_deref(), Some("Camera Faithful"));
assert!(reread_profile.fields.is_empty());
let reread_upright = reread_warnings.iter().find(|w| w.code == crate::import::ImportWarningCode::UprightGeometry).unwrap();
assert_eq!(reread_upright.path, photo_path);
assert_eq!(reread_upright.mode, Some(crate::import::ImportUprightMode::Auto));
assert!(reread_upright.fields.is_empty());
assert!(
reread["warnings"]
.as_array()
.unwrap()
.iter()
.any(|w| w["code"] == "cameraProfile" && w["profile"] == "Camera Faithful" && w["path"] == photo_path),
"{reread}"
);
assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "auto"), "{reread}");
for (source, mode) in [("0", "off"), ("2", "level"), ("3", "vertical"), ("4", "full"), ("5", "guided"), ("9", "unknown")] {
let mode_xmp = xmp.replace("crs:PerspectiveUpright=\"1\"", &format!("crs:PerspectiveUpright=\"{source}\""));
std::fs::write(&sidecar, mode_xmp).unwrap();
let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap();
assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == mode), "{source}: {reread}");
}
// A foreign name colliding with a built-in profile label remains warning metadata only.
let collision = src.join("collision.png");
write_png(&collision, 8);
std::fs::write(src.join("collision.xmp"), xmp.replace("Camera Faithful", "Color")).unwrap();
let collision_report = s.execute("library.import", &json!({"paths": [collision.to_string_lossy()]})).unwrap();
assert!(
collision_report["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Color"),
"{collision_report}"
);
let collision_photo = s.catalog.photos().find(|photo| photo.file_name == "collision.png").unwrap();
assert_eq!(collision_photo.develop.profile.id, "lc.color");
let unmapped = crate::import::ImportWarning {
path: photo_path.clone(),
code: crate::import::ImportWarningCode::UnmappedXmp,
profile: None,
mode: None,
fields: vec!["Tint".into()],
};
let unmapped_wire = serde_json::to_value(&unmapped).unwrap();
assert_eq!(serde_json::from_value::<crate::import::ImportWarning>(unmapped_wire).unwrap(), unmapped);
// A second import still diagnoses the untouched foreign packet, while an XMP-free photo does not.
drop(s);
let clean = src.join("clean.png");
write_png(&clean, 7);
let mut s = Session::new().with_fs();
let clean_report = s.execute("library.import", &json!({"paths": [clean.to_string_lossy()]})).unwrap();
assert!(clean_report.get("warnings").is_none(), "{clean_report}");
let repeat = s.execute("library.import", &json!({"paths": [photo.to_string_lossy()]})).unwrap();
assert_eq!(repeat["warnings"].as_array().unwrap().len(), 2, "{repeat}");
let _ = std::fs::remove_dir_all(&src);
}
@@ -566,6 +781,45 @@ fn external_edit_copy_is_stacked() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #920: with several photos selected, each gets its own `-Edit` copy stacked on its
/// original, the copies end up selected and are all reported; `ids` picks photos explicitly.
#[test]
fn external_edit_copies_every_selected_photo() {
let dir = temp_dir("extedit-many");
let paths: Vec<String> = ["a", "b", "c"]
.iter()
.zip(9u8..)
.map(|(n, seed)| {
let p = dir.join(format!("{n}.png"));
// different bytes: the import keeps one of identical files
write_png(&p, seed);
p.to_string_lossy().into_owned()
})
.collect();
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": paths})).unwrap();
let originals: Vec<u64> = imported["imported"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
assert_eq!(originals.len(), 3);
s.execute("library.select", &json!({"ids": originals})).unwrap();
let r = s.execute("photo.editExternal", &json!({})).unwrap();
let copies = r["copies"].as_array().unwrap();
assert_eq!(copies.len(), 3, "{r}");
for (copy, orig) in copies.iter().zip(&originals) {
assert_eq!(copy["original"].as_u64(), Some(*orig));
assert!(copy["path"].as_str().unwrap().ends_with("-Edit.tif"), "{copy}");
let new = lightcraft_catalog::PhotoId(copy["id"].as_u64().unwrap());
let st = s.catalog.stack_of(new).expect("stacked");
assert_eq!((st.top(), st.photos.contains(&lightcraft_catalog::PhotoId(*orig))), (new, true));
assert!(s.selection.contains(new), "the copies are selected");
}
assert_eq!(r["path"], copies[0]["path"], "the single-copy fields name the first copy");
// an edit since the first copy: identical bytes would be skipped as a duplicate import
s.execute("develop.set", &json!({"ids": [originals[1]], "control": "light.exposure", "value": 0.5})).unwrap();
let one = s.execute("photo.editExternal", &json!({"ids": [originals[1]]})).unwrap();
assert_eq!(one["copies"].as_array().map(Vec::len), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
/// Edit in PhotoCraft (engine half): a flat 16-bit PSD `-Edit` copy, which PhotoCraft saves back
/// to in place (it writes a TIFF only through Save As); its saved pixels come back on Reload.
#[test]
+28 -1
View File
@@ -57,6 +57,8 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
if let Ok(c) = std::fs::canonicalize(root) {
linked.insert(c);
}
// Include aliases to the library in the exclusion, not just its lexical path.
let skip_canonical = skip.and_then(|p| std::fs::canonicalize(p).ok());
// (path, depth of the folder it is in, is a folder); popped in path order
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
while let Some((path, depth, is_dir)) = stack.pop() {
@@ -66,7 +68,7 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
}
continue;
}
if skip.is_some_and(|s| path == s) {
if skip.is_some_and(|s| path == s) || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) {
continue;
}
if depth >= limits.max_depth {
@@ -165,6 +167,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// Folder imports must not revisit the user's own library through symlinks
/// or through an alias to the library's parent.
#[cfg(unix)]
#[test]
fn skipped_library_stays_skipped_through_symlinks_and_ancestors() {
let d = tmp("skip-alias");
let root = d.join("incoming");
let library = d.join("library");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(library.join("Originals")).unwrap();
std::fs::write(root.join("ordinary.jpg"), b"photo").unwrap();
std::fs::write(library.join("Originals").join("library-only.jpg"), b"original").unwrap();
std::os::unix::fs::symlink(&library, root.join("library-link")).unwrap();
std::os::unix::fs::symlink(&d, root.join("parent-link")).unwrap();
let photos = files_in(&root, Some(&library), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
assert!(photos.files.contains(&root.join("ordinary.jpg")));
assert!(!photos.files.iter().any(|p| p.ends_with("library-only.jpg")), "{photos:?}");
// Scanning a subfolder already inside the excluded library is also forbidden.
let inside = files_in(&library.join("Originals"), Some(&library), Limits::default(), |_| true);
assert!(inside.files.is_empty(), "{inside:?}");
let _ = std::fs::remove_dir_all(&d);
}
/// A link back up the tree (a loop) is followed at most once: the walk ends.
#[cfg(unix)]
#[test]
+48
View File
@@ -0,0 +1,48 @@
//! Opt-in test of the embedded-preview read path on a **real file the user has** — typically one on a
//! network share, which is where reading only the header instead of the whole raw matters:
//!
//! LC_PREVIEW_RAW="/run/user/1000/gvfs/smb-share:.../DSCF0001.RAF" \
//! cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture
//!
//! It reports what the preview cost and fails when the read is not the small part of the file the
//! header path promises. Skipped without `LC_PREVIEW_RAW` (there is no committed raw fixture: media
//! is never committed, see AGENTS.md).
use std::time::Instant;
#[test]
#[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"]
fn a_preview_reads_only_the_header_and_the_preview() {
let Ok(path) = std::env::var("LC_PREVIEW_RAW") else {
eprintln!("skipped: set LC_PREVIEW_RAW=<path to a raw file>");
return;
};
let file_len = std::fs::metadata(&path).expect("the file exists").len();
assert!(file_len > 0, "empty file");
let started = Instant::now();
let window =
lightcraft_engine::files::preview_window(&path).expect("the file reads").expect("it carries an embedded preview the header can point at");
let elapsed = started.elapsed();
let (_, preview, read) = window;
let frac = read.fraction();
println!(
"preview of {path}\n read {} of {} bytes ({:.1} %) in {:.2} s{}",
read.read,
read.total,
frac * 100.0,
elapsed.as_secs_f64(),
if read.planned {
" [plan]"
} else if read.header_path {
" [header path]"
} else {
" [whole file]"
}
);
assert!(read.header_path, "the header or the plan must answer for a raw whose preview is not the whole file");
assert_eq!(read.total, file_len, "the report covers the whole file");
assert!(frac < 0.5, "a preview must not cost most of the file: read {} of {} bytes", read.read, read.total);
assert!(preview.starts_with(&[0xff, 0xd8]), "the preview bytes are a JPEG: {:?}", preview.get(..4));
}
+28 -5
View File
@@ -84,15 +84,21 @@ impl CropGeometry {
/// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's
/// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle,
/// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so
/// they all give the same crop (issue #742).
/// they all give the same crop (issue #742). A crop that is the largest of its aspect at its
/// own angle (the full photo, or one an earlier turn shrank to fit) stays the largest at the new
/// angle, so it grows back as the angle returns towards 0° (issue #1388).
pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry {
let cand = CropGeometry { rect: self.rect, angle: angle_deg };
if cand.is_within_image(w, h) {
return cand;
}
let r = self.rect_px(w, h);
// a degenerate rect has no aspect to keep: fall back to the image's own
let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0);
let largest = crop_fit_angle(w, h, self.angle, aspect).rect;
let is_largest = [(self.rect.x0, largest.x0), (self.rect.y0, largest.y0), (self.rect.x1, largest.x1), (self.rect.y1, largest.y1)]
.iter()
.all(|(a, b)| (a - b).abs() < 1e-9);
let cand = CropGeometry { rect: self.rect, angle: angle_deg };
if !is_largest && cand.is_within_image(w, h) {
return cand;
}
crop_fit_angle(w, h, angle_deg, aspect)
}
}
@@ -172,6 +178,23 @@ mod tests {
}
}
#[test]
fn with_angle_grows_a_fitted_crop_back_as_the_angle_returns() {
// issue #1388: the crop a turn shrank to fit is the largest of its aspect; turning back
// gives the largest at the new angle, not the shrunk one again
for aspect in [None, Some(1.0), Some(16.0 / 9.0)] {
let mut c = crop_fit_angle(600.0, 400.0, 0.0, aspect);
for ang in [10.0, 25.0, -25.0, 7.0, 0.0] {
c = c.with_angle(ang, 600.0, 400.0);
let want = crop_fit_angle(600.0, 400.0, ang, aspect);
assert!(
(c.rect.width() - want.rect.width()).abs() < 1e-9 && (c.rect.height() - want.rect.height()).abs() < 1e-9,
"{aspect:?} at {ang}°: {c:?} vs {want:?}"
);
}
}
}
#[test]
fn with_angle_survives_a_degenerate_crop() {
// zero height: no aspect to keep (width / height = ∞)
+26 -5
View File
@@ -51,11 +51,11 @@ impl Homography {
let c01 = m[5] * m[6] - m[3] * m[8];
let c02 = m[3] * m[7] - m[4] * m[6];
let det = m[0] * c00 + m[1] * c01 + m[2] * c02;
if det.abs() < 1e-300 {
if !det.is_finite() || det.abs() < 1e-300 {
return None;
}
let inv = 1.0 / det;
Some(Homography([
let coefficients = [
c00 * inv,
(m[2] * m[7] - m[1] * m[8]) * inv,
(m[1] * m[5] - m[2] * m[4]) * inv,
@@ -65,11 +65,17 @@ impl Homography {
c02 * inv,
(m[1] * m[6] - m[0] * m[7]) * inv,
(m[0] * m[4] - m[1] * m[3]) * inv,
]))
];
coefficients.iter().all(|v| v.is_finite()).then_some(Homography(coefficients))
}
/// The homography mapping `src[i]` → `dst[i]` (4 correspondences, DLT with h33 = 1).
pub fn from_quads(src: &[Point; 4], dst: &[Point; 4]) -> Option<Homography> {
// Failed feature matches and invalid perspective parameters can contain
// non-finite coordinates. Do not return a transform that poisons rendering.
if src.iter().chain(dst.iter()).any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
let mut a = [[0.0f64; 9]; 8];
for i in 0..4 {
let (x, y) = (src[i].x, src[i].y);
@@ -80,7 +86,7 @@ impl Homography {
// Gaussian elimination with partial pivoting on the 8×8 system (last column = rhs).
for col in 0..8 {
let piv = (col..8).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?;
if a[piv][col].abs() < 1e-12 {
if !a[piv][col].is_finite() || a[piv][col].abs() < 1e-12 {
return None;
}
a.swap(col, piv);
@@ -94,7 +100,7 @@ impl Homography {
}
}
let h: Vec<f64> = (0..8).map(|i| a[i][8] / a[i][i]).collect();
Some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]))
h.iter().all(|v| v.is_finite()).then_some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]))
}
}
@@ -121,4 +127,19 @@ mod tests {
let src = [Point::new(0.0, 0.0); 4];
assert!(Homography::from_quads(&src, &src).is_none());
}
#[test]
fn nonfinite_projective_and_affine_transforms_are_rejected() {
let src = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
let mut invalid = src;
invalid[2].x = f64::NAN;
assert!(Homography::from_quads(&invalid, &src).is_none());
invalid[2].x = f64::INFINITY;
assert!(Homography::from_quads(&src, &invalid).is_none());
assert!(Homography([f64::NAN, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none());
assert!(Homography([f64::INFINITY, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none());
assert!(Affine([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none());
assert!(Affine([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none());
assert_eq!(Affine::IDENTITY.inverse(), Some(Affine::IDENTITY));
}
}
+3 -2
View File
@@ -234,12 +234,13 @@ impl Affine {
}
pub fn inverse(&self) -> Option<Affine> {
let det = self.determinant();
if det.abs() < 1e-300 {
if !det.is_finite() || det.abs() < 1e-300 {
return None;
}
let [a, b, c, d, e, f] = self.0;
let inv = 1.0 / det;
Some(Affine([d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv]))
let coefficients = [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv];
coefficients.iter().all(|v| v.is_finite()).then_some(Affine(coefficients))
}
/// Bounding box of a transformed rectangle.
pub fn transform_rect_bbox(&self, r: &Rect) -> Rect {
+73 -8
View File
@@ -229,10 +229,19 @@ pub(crate) fn rgb_words(img: &Rgb32f) -> &[f32] {
// ---------------------------------------------------------------------------------------------
// Filters (twins of `lightcraft_raster::blur` / `resample` and `lightcraft_pipeline::local`)
/// Pixels each blur thread slides its running sum over (at least; longer for large radii, so the
/// Rows each `box_v` thread slides its running sum over (at least; longer for large radii, so the
/// priming sum stays a small share of the work).
const CHUNK: usize = 16;
/// One box pass of radius `r` (clamped edges) over a `w × h` image of `nc` interleaved channels,
/// `src → dst`: horizontal or vertical.
fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (usize, usize), nc: usize, r: usize) {
let chunk = (2 * r + 1).clamp(CHUNK, 128);
let p = [w as u32, h as u32, nc as u32, r as u32, chunk as u32];
let (k, groups) = if horizontal { ("box_h", groups2(w, h, [64, 4])) } else { ("box_v", groups2(w, h.div_ceil(chunk), [64, 4])) };
cx.run(k, &p, &[Some(src), Some(dst)], groups);
}
/// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way
/// (`lightcraft_raster::blur::gaussian`).
pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf {
@@ -240,18 +249,15 @@ pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize
return cx.copy(src);
}
let radii = lightcraft_raster::blur::box_radii(sigma);
let passes: Vec<(&str, usize)> = radii.iter().map(|r| ("box_h", *r)).chain(radii.iter().map(|r| ("box_v", *r))).filter(|p| p.1 > 0).collect();
let passes: Vec<(bool, usize)> = radii.iter().map(|r| (true, *r)).chain(radii.iter().map(|r| (false, *r))).filter(|p| p.1 > 0).collect();
if passes.is_empty() {
return cx.copy(src);
}
// ping-pong between two buffers
let mut bufs = [cx.gpu.buffer(w * h * nc), cx.gpu.buffer(w * h * nc)];
for (i, (k, r)) in passes.iter().enumerate() {
let chunk = (2 * r + 1).clamp(CHUNK, 128);
let groups = if *k == "box_h" { groups2(w.div_ceil(chunk), h, [64, 4]) } else { groups2(w, h.div_ceil(chunk), [64, 4]) };
let p = [w as u32, h as u32, nc as u32, *r as u32, chunk as u32];
for (i, (horizontal, r)) in passes.iter().enumerate() {
let (out, prev) = (&bufs[i % 2], &bufs[(i + 1) % 2]);
cx.run(k, &p, &[Some(if i == 0 { src } else { prev }), Some(out)], groups);
box_pass(cx, *horizontal, if i == 0 { src } else { prev }, out, (w, h), nc, *r);
}
let last = (passes.len() - 1) % 2;
let [a, b] = std::mem::replace(&mut bufs, [cx.gpu.buffer(0), cx.gpu.buffer(0)]);
@@ -712,6 +718,27 @@ pub fn render(
_ => Err(m),
}
});
// every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once).
// The masks buffer holds each visible mask's alpha back-to-back in the same `list` order the
// single-mask overlay uses, so read the whole buffer back in ONE GPU->CPU transfer and split it
// on the CPU, instead of one synchronous readback per mask (which stalls the pipeline N times).
let all_overlay_alphas: Option<Vec<Plane>> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) {
masks.as_ref().map(|all| {
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
if list.is_empty() {
return Vec::new();
}
let raw: Vec<f32> = cx.read(all, n * list.len());
let mut alphas = Vec::with_capacity(list.len());
for mi in 0..list.len() {
let s = mi * n;
alphas.push(Plane { width: w, height: h, data: raw[s..s + n].to_vec() });
}
alphas
})
} else {
None
};
let data: Vec<[u8; 4]> = cx.read(&out, n);
let image = Rgba8 { width: w, height: h, data };
lap("finish + readback", &mut t, &mut cx);
@@ -747,7 +774,7 @@ pub fn render(
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
}
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
image = image.crop(k.x, k.y, k.w, k.h);
@@ -1132,6 +1159,33 @@ mod tests {
assert!(covered > 0 && covered < w * h, "{covered} px covered");
}
/// The blur against `lightcraft_raster::blur::gaussian`: widths that do and don't fill the last
/// workgroup, windows wider than the image, and 1, 2 (as two planes) and 3 channels.
#[test]
fn gaussian_matches_cpu() {
use lightcraft_raster::blur;
let Some(gpu) = crate::device() else { return };
let _scope = crate::ctx::RenderScope::new(gpu);
let v = |x: usize, y: usize, c: usize| ((x * 7 + y * 13 + c * 5) % 17) as f32 / 16.0 + if x % 61 < 3 { 2.0 } else { 0.0 };
for (w, h) in [(1000, 5), (257, 3), (5, 4), (1, 9)] {
for sigma in [0.8f32, 2.0, 4.5, 30.0, 250.0, 1000.0] {
let planes: Vec<Plane> = (0..3).map(|c| Plane::from_fn(w, h, |x, y| v(x, y, c))).collect();
let want: Vec<Plane> = planes.iter().map(|p| blur::gaussian(p, sigma)).collect();
for nc in [1, 2, 3] {
let words: Vec<f32> = (0..w * h).flat_map(|i| (0..nc).map(move |c| (i, c))).map(|(i, c)| planes[c].data[i]).collect();
let mut cx = Cx::new(gpu);
let src = gpu.upload(&words);
let out = gaussian(&mut cx, &src, w, h, nc, sigma);
let got: Vec<f32> = cx.read(&out, w * h * nc);
for (i, g) in got.iter().enumerate() {
let want = want[i % nc].data[i / nc];
assert!((g - want).abs() < 1e-5 * (1.0 + sigma / 10.0), "{w}×{h} nc={nc} sigma={sigma}: word {i} {g} vs {want}");
}
}
}
}
}
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
#[test]
#[ignore]
@@ -1139,6 +1193,7 @@ mod tests {
let Some(gpu) = crate::device() else { return };
let _scope = crate::ctx::RenderScope::new(gpu);
let (w, h) = (6000, 4000);
eprintln!("{} ({:?})", gpu.info.name, gpu.info.backend);
let src = gpu.upload(&vec![0.5f32; w * h * 3]);
let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| {
let mut best = f64::MAX;
@@ -1152,9 +1207,19 @@ mod tests {
}
eprintln!("{name:<40} {best:.1} ms");
};
for nc in [1, 2, 3] {
let out = gpu.buffer(w * h * nc);
// noise reduction's radii, Texture's and Dehaze's at 6000 px
for r in [2, 11, 120] {
for (k, horizontal) in [("box_h", true), ("box_v", false)] {
time(&format!("{k} nc={nc} r={r}"), &mut |cx| box_pass(cx, horizontal, &src, &out, (w, h), nc, r));
}
}
}
for nc in [1, 2, 3] {
time(&format!("gaussian nc={nc} sigma=2"), &mut |cx| drop(gaussian(cx, &src, w, h, nc, 2.0)));
}
time("gaussian nc=3 sigma=4.5 (colour NR)", &mut |cx| drop(gaussian(cx, &src, w, h, 3, 4.5)));
time("map log_lum", &mut |cx| {
let o = cx.gpu.buffer(w * h);
map(cx, "log_lum_k", w * h, &[], [Some(&src), None, None], &o);
+9 -13
View File
@@ -1,6 +1,6 @@
// Box passes (clamped edges) over an image of NC interleaved channels; three of each approximate a
// Gaussian (`lightcraft_raster::blur::gaussian`). Each thread slides a running sum over CH pixels.
// P: w, h, nc, r, ch. Bindings: src, dst.
// Gaussian (`lightcraft_raster::blur::gaussian`).
// P: w, h, nc, r, ch (box_v only). Bindings: src, dst.
// Pixel `i` (all nc ≤ 3 channels at once: one contiguous read per pixel).
fn ld(i: u32, nc: u32) -> vec3<f32> {
@@ -25,31 +25,27 @@ fn st(i: u32, nc: u32, v: vec3<f32>) {
}
}
// Horizontal: one thread per CH consecutive pixels of a row.
// Horizontal: one thread per pixel, summing its 2r + 1 window; adjacent threads read adjacent
// pixels, which the cache serves. (Threads that each slid a running sum over 16 pixels read pixels
// 16 apart across a workgroup: on a Radeon 780M that pass took 9–15× as long as `box_v`.)
@compute @workgroup_size(64, 4)
fn box_h(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let nc = pu(2u);
let r = i32(pu(3u));
let ch = pu(4u);
let x = g.x;
let y = g.y;
let x0 = g.x * ch;
if (y >= h || x0 >= w) {
if (x >= w || y >= h) {
return;
}
let last = i32(w) - 1;
let inv = 1.0 / f32(2 * r + 1);
let row = y * w;
let x1 = min(x0 + ch, w);
var acc = vec3<f32>(0.0);
for (var k = -r; k <= r; k++) {
acc += ld(row + u32(clamp(i32(x0) + k, 0, last)), nc);
}
for (var x = x0; x < x1; x++) {
st(row + x, nc, acc * inv);
acc = acc + ld(row + u32(min(i32(x) + r + 1, last)), nc) - ld(row + u32(max(i32(x) - r, 0)), nc);
acc += ld(row + u32(clamp(i32(x) + k, 0, last)), nc);
}
st(row + x, nc, acc * (1.0 / f32(2 * r + 1)));
}
// Vertical: one thread per CH consecutive rows of a column (the CPU re-primes every BAND rows the
+2 -2
View File
@@ -11,11 +11,11 @@ license.workspace = true
# Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the
# other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL)
# code structure; gamut-heic has no HEVC decoder of its own).
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
rayon = { workspace = true }
[features]
+2 -1
View File
@@ -100,7 +100,8 @@ pub struct Shot {
pub aperture: Option<f64>,
/// Focus distance, metres.
pub distance: Option<f64>,
/// Crop factor of the camera (from [`LensDb::find_camera`]); the profile's own when unknown.
/// Crop factor of the recorded frame: the camera's (from [`LensDb::find_camera`]), or larger when the camera
/// recorded a crop of its sensor; the profile's own when unknown.
pub crop_factor: Option<f64>,
/// Image size in pixels (the frame the correction is expressed for; the optical centre is its centre).
pub width: f64,
+37 -13
View File
@@ -10,13 +10,6 @@ use serde_json::{Value, json};
use crate::backend::{Backend, ProgressHook};
/// File extensions recognised as photos when expanding folders.
pub const PHOTO_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl",
"gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
];
/// Headless backend: a [`Session`] with filesystem hooks.
pub struct Headless {
pub session: Session,
@@ -24,8 +17,12 @@ pub struct Headless {
}
impl Drop for Headless {
/// Snapshot a persistent library on exit (a no-op for in-memory sessions).
/// Snapshot a persistent library on exit (a no-op for in-memory sessions), after any Apple
/// Photos import still running (`export.addToPhotos {wait: false}`) has ended: the process
/// must not end in the middle of one, abandoning osascript and the import's result.
fn drop(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
self.session.wait_for_photos_imports();
let _ = self.session.close_library();
}
}
@@ -85,6 +82,8 @@ impl Headless {
};
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
let write = &mut lightcraft_engine::export::write_file;
// Add to Apple Photos: refused before anything is written when it can't be done
let after = self.session.after_export(&opts)?;
let items = prepare_batch(&mut self.session, &ids, &opts)?;
let total = items.len();
let mut hook = self.progress.take();
@@ -109,6 +108,9 @@ impl Headless {
}
// Single-photo exports also report path/width/height at the top level (back-compat).
let mut out = files.first().cloned().unwrap_or_else(|| json!({}));
if let Some(photos) = after.run(&files, false) {
out["applePhotos"] = photos;
}
out["files"] = json!(files);
Ok(out)
}
@@ -133,7 +135,7 @@ impl Backend for Headless {
"engine.commands" => {
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
"enabled": self.session.active().is_some()}));
Ok(Value::Array(v))
}
@@ -166,15 +168,16 @@ impl Backend for Headless {
}
/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files
/// and make paths absolute.
/// and make paths absolute. Folders take the extensions `library.import` takes
/// (`lightcraft_engine::import::EXTENSIONS`), so the two can't drift apart (issue #1080).
pub fn expand_paths(paths: &[String]) -> Vec<String> {
let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string();
let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()));
let mut out = Vec::new();
for p in paths {
let path = Path::new(p);
if path.is_dir() {
let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo);
let w =
lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), lightcraft_engine::import::is_supported);
out.extend(w.files.iter().map(|f| absolute(f)));
} else {
// Explicit files are kept even with unknown extensions (the probe decides).
@@ -185,7 +188,7 @@ pub fn expand_paths(paths: &[String]) -> Vec<String> {
}
/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless),
/// `.avif` — via the shared export encoder (embeds an sRGB profile).
/// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile).
pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, String> {
use lightcraft_engine::export::{ExportFormat, ExportOptions};
let format = ExportFormat::parse(ext).unwrap_or(ExportFormat::Png);
@@ -217,6 +220,27 @@ mod tests {
assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}");
}
/// `--import <folder>` picks up every extension `library.import` takes, HEIC included (issue #1080).
#[test]
fn expand_paths_takes_the_engine_extensions() {
let dir = std::env::temp_dir().join(format!("lc-mcp-expand-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for e in lightcraft_engine::import::EXTENSIONS {
std::fs::write(dir.join(format!("a.{e}")), b"x").unwrap();
}
std::fs::write(dir.join("B.HEIC"), b"x").unwrap();
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
let found = expand_paths(&[dir.to_string_lossy().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
let ext = |f: &String| Path::new(f).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let mut got: Vec<String> = found.iter().map(ext).collect();
got.sort();
let mut want: Vec<String> = lightcraft_engine::import::EXTENSIONS.iter().map(|e| e.to_string()).chain(["HEIC".to_string()]).collect();
want.sort();
assert_eq!(got, want);
}
/// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock.
#[test]
fn headless_uses_the_system_clock() {
+1 -1
View File
@@ -23,7 +23,7 @@ mod tools;
pub use backend::{Backend, ProgressHook, Remote};
pub use base64::{base64_decode, base64_encode};
pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image};
pub use headless::{Headless, encode_image, expand_paths, write_image};
pub use server::{PROTOCOL_VERSION, Server};
pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions};
+1 -1
View File
@@ -25,7 +25,7 @@ const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \
Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \
Photos have integer ids; most editing acts on the *active* photo (`set_develop`, `crop` and `export` with an explicit photo id end the current edit interaction, replace the selection with that one photo and make it active; `get_develop` and `render_photo` do not change the selection). \
Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \
render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \
coordinates 0..1 with the origin at the top-left. Every action is a command: command_list shows ids and parameter \
+102
View File
@@ -2,6 +2,13 @@ use serde_json::{Value, json};
use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name};
/// An undated file takes its own date (#1216): give the fixture the date its folder template expects (noon UTC,
/// the same day in every time zone the tests run in).
fn file_dated_2026_01_14(path: &std::path::Path) {
let noon = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_392_000);
std::fs::File::options().write(true).open(path).unwrap().set_modified(noon).unwrap();
}
fn server() -> Server {
Server::new(Box::new(Headless::demo()))
}
@@ -82,6 +89,7 @@ fn import_tool_copies_with_a_folder_template() {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("a.png"), png).unwrap();
file_dated_2026_01_14(&src.join("a.png"));
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args =
@@ -152,6 +160,99 @@ fn export_tool_forwards_its_advertised_options() {
let _ = std::fs::remove_dir_all(&base);
}
/// Issue #236: `app.export {addToPhotos, photosAlbum}` through the headless backend (the MCP
/// `export` tool, `lightcraft-cli run`): the files written go to Photos and the result says how
/// it went; where Photos isn't (not macOS), it is refused before anything is written. A fake
/// runner replaces the real one first: tests never start osascript or Photos.
#[test]
fn export_adds_to_apple_photos_where_there_is_photos() {
use std::sync::{Arc, Mutex};
let mut b = Headless::demo();
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let c = calls.clone();
b.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
c.lock().unwrap().push(args.to_vec());
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID-1/L0/001\n".into(), stderr: String::new() })
}));
let base = std::env::temp_dir().join(format!("lc-mcp-export-photos-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let r = call_tool(&mut b, "export", &json!({"dir": base.to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "From \"MCP\""}));
if cfg!(target_os = "macos") {
assert!(!r.is_error, "{r:?}");
let out = r.structured.unwrap();
assert_eq!(out["applePhotos"]["imported"], 1, "{out}");
assert_eq!(out["applePhotos"]["album"], "From \"MCP\"");
let written = out["files"][0]["path"].as_str().unwrap().to_string();
let args = calls.lock().unwrap().clone();
assert_eq!(args.len(), 1);
assert_eq!(args[0].last(), Some(&written));
} else {
assert!(r.is_error, "{r:?}");
assert!(format!("{:?}", r.content).contains("only available on macOS"), "{r:?}");
assert!(!base.exists(), "nothing written");
assert!(calls.lock().unwrap().is_empty());
}
let _ = std::fs::remove_dir_all(&base);
}
/// Review of #236: a one-shot process (`lightcraft-cli run`, an MCP server reaching EOF) must not
/// end in the middle of an Apple Photos import, abandoning osascript and the result. The test runs
/// itself as a child process (`LC_PHOTOS_CHILD`): the child adds a file with a fake runner that
/// takes a while and then leaves a marker, prints the reply and ends as the CLI does (dropping
/// its backend). The parent checks the import had finished: by default the call waited for
/// Photos, and with `wait: false` the backend waited before the process ended. Never osascript:
/// the child's session has no file-system hooks and only the fake runner.
#[test]
fn a_one_shot_process_never_ends_during_a_photos_import() {
use std::sync::Arc;
use std::time::Duration;
if let (Ok(dir), Ok(mode)) = (std::env::var("LC_PHOTOS_CHILD"), std::env::var("LC_PHOTOS_CHILD_MODE")) {
let dir = std::path::PathBuf::from(dir);
let file = dir.join("a.jpg");
let marker = dir.join("imported");
let mut h = Headless::new(lightcraft_engine::Session::with_demo());
h.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
std::thread::sleep(Duration::from_millis(400));
std::fs::write(&marker, args.last().cloned().unwrap_or_default()).unwrap();
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID-1\n".into(), stderr: String::new() })
}));
let mut p = json!({"paths": [file.to_string_lossy()]});
if mode == "nowait" {
p["wait"] = json!(false);
}
let r = h.call("engine.execute", json!({"command": "export.addToPhotos", "params": p})).unwrap();
println!("CHILD-REPLY {r}");
drop(h); // what lightcraft-cli does before it exits
return;
}
for mode in ["default", "nowait"] {
let dir = std::env::temp_dir().join(format!("lc-mcp-photos-child-{mode}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.jpg"), b"jpeg").unwrap();
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::a_one_shot_process_never_ends_during_a_photos_import", "--nocapture", "--test-threads=1"])
.env("LC_PHOTOS_CHILD", &dir)
.env("LC_PHOTOS_CHILD_MODE", mode)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{mode}: {stdout}\n{}", String::from_utf8_lossy(&out.stderr));
let reply: Value = stdout
.lines()
.find_map(|l| l.split_once("CHILD-REPLY ").map(|(_, j)| serde_json::from_str(j).unwrap()))
.unwrap_or_else(|| panic!("{mode}: no reply in {stdout}"));
let marker = std::fs::read_to_string(dir.join("imported")).unwrap_or_else(|_| panic!("{mode}: the process ended before the import did"));
assert!(marker.ends_with("a.jpg"), "{marker}");
if mode == "default" {
assert_eq!((reply["running"].as_bool(), reply["imported"].as_u64()), (Some(false), Some(1)), "waited: {reply}");
} else {
assert_eq!(reply["running"], true, "{reply}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
/// The `import` helper moves: renamed into the folder template, the source removed.
#[test]
fn import_tool_moves_with_a_folder_template() {
@@ -162,6 +263,7 @@ fn import_tool_moves_with_a_folder_template() {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("sample.png"), png).unwrap();
file_dated_2026_01_14(&src.join("sample.png"));
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(),
+16 -7
View File
@@ -182,7 +182,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"get_develop",
"Get develop settings",
"The full develop settings JSON of a photo (white balance, light, color, curves, mixer, grading, effects, crop, masks, spots…).",
json!({"id": photo_id}),
json!({"id": {"type": "integer", "description": "Photo id (default: the active photo). Reads without changing the selection."}}),
&[],
),
tool(
@@ -216,7 +216,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"Render photo",
"Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.",
json!({
"id": photo_id,
"id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"},
"size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"},
"format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"},
"path": {"type": "string", "description": "Also save the rendered file here"}
@@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]},
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"},
@@ -248,7 +248,10 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"percent": {"type": "number", "description": "Output size as a percentage of full size"},
"dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"},
"ppi": {"type": "integer", "description": "Print resolution written into the file (default 240)"},
"quality": {"type": "integer", "description": "JPEG/AVIF quality 1..100"},
"quality": {"type": "integer", "description": "JPEG/AVIF/lossy JPEG XL quality 1..100"},
"jxlLossless": {"type": "boolean", "description": "JPEG XL: mathematically lossless (quality unused); default false"},
"jxlEffort": {"type": "string", "enum": ["fast", "normal"], "description": "JPEG XL encoder effort: normal (default) makes files about a quarter smaller than fast, in about twice the time"},
"jxlProgressive": {"type": "boolean", "description": "JPEG XL: progressive file, a coarse image first (default false)"},
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
@@ -256,9 +259,12 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
"removeLocation": {"type": "boolean"},
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit and PNG as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"},
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
"hdrTransfer": {"type": "string", "enum": ["pq", "hlg"], "description": "Transfer curve of HDR AVIF output (default pq): pq = SMPTE ST 2084, hlg = ITU-R BT.2100 hybrid log-gamma"},
"addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"},
"photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"},
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
}),
&[],
@@ -738,6 +744,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"colorSpace",
"bitDepth",
"hdr",
"hdrTransfer",
"preset",
"background",
"resize",
@@ -745,6 +752,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"conflict",
"tiffCompression",
"dngCompression",
"addToPhotos",
"photosAlbum",
],
),
)
+13
View File
@@ -103,15 +103,28 @@ pub fn detect_clip(img: &Rgb32f) -> f32 {
if maxv >= 1.0 || piled { level * 0.995 } else { 1.0 }
}
/// The colour tags a raw source starts from when the caller knows better than the file (`None`: the
/// file's own tags). Called with the decoded raw, its samples still there, and the file's bytes.
pub type RawColour<'a> = dyn Fn(&mut lightcraft_raw::RawImage, &[u8]) -> Option<ColorData> + Sync + 'a;
/// Decode a photo (raw or standard format) into a frame no larger than `max_edge` (when given).
/// `orient` applies the EXIF orientation to the pixels.
pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, orient: bool) -> Result<Frame> {
load_frame_with(bytes, max_edge, orient, &|_, _| None)
}
/// [`load_frame`] with a raw's colour tags from `colour`: a raw without colour matrices of its own
/// (CR2, NEF, ARW…) starts from a colour the app works out (issue #965).
pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colour: &RawColour) -> Result<Frame> {
let metadata = lightcraft_meta::extract(bytes);
let exposure = exif_exposure(&metadata);
if lightcraft_raw::probe(bytes).is_some() {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
// geometric lens corrections stay with the develop settings ("profile corrections")
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(color) = colour(&mut raw, bytes) {
raw.color = color;
}
let small = max_edge.is_some_and(|m| m <= 1200);
let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?;
+1 -1
View File
@@ -23,7 +23,7 @@ pub mod pano;
pub mod ransac;
pub mod synth;
pub use frame::{Frame, FrameColor, load_frame};
pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with};
pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr};
pub use output::{DngSamples, write_linear_dng};
pub use pano::{PanoOptions, PanoResult, Projection, stitch};
+1
View File
@@ -53,6 +53,7 @@ pub fn color_data(color: &FrameColor) -> ColorData {
baseline_exposure: 0.0,
baseline_sharpness: None,
profile: Default::default(),
kelvin_presets: None,
}
}
}
+23 -2
View File
@@ -25,8 +25,19 @@ fn num(s: &str) -> Option<u32> {
impl DateTime {
fn valid(self) -> Option<Self> {
let ok = (1..=12).contains(&self.month)
&& (1..=31).contains(&self.day)
// A timestamp must represent a real Gregorian calendar day, not just
// numbers in the range 1..=31. Invalid dates would otherwise be
// accepted from EXIF/XMP and silently normalized during time arithmetic.
let leap = self.year.is_multiple_of(4) && (!self.year.is_multiple_of(100) || self.year.is_multiple_of(400));
let max_day = match self.month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => 0,
};
let ok = self.day >= 1
&& self.day <= max_day
&& self.hour < 24
&& self.minute < 60
&& self.second < 61
@@ -165,6 +176,16 @@ mod tests {
assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none());
assert!(DateTime::parse_exif(" : : : : ").is_none());
assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none());
// Invalid calendar days must never enter persisted capture metadata.
for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] {
assert!(DateTime::parse_exif(value).is_none(), "{value}");
}
assert!(DateTime::parse_exif("2024:02:29").is_some());
assert!(DateTime::parse_exif("2000:02:29").is_some());
for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] {
assert!(DateTime::parse_iso(value).is_none(), "{value}");
}
assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some());
assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0);
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500);
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12);
+12 -1
View File
@@ -96,7 +96,9 @@ struct Pending {
/// Parse a GPX document.
pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
let mut reader = quick_xml::Reader::from_str(s);
reader.config_mut().check_end_names = false;
// Mismatched end tags must be rejected; otherwise the path stack can
// attribute a point's time or segment to the wrong enclosing element.
reader.config_mut().check_end_names = true;
let mut path: Vec<Vec<u8>> = Vec::new();
let mut seen_gpx = false;
let mut log = Tracklog::default();
@@ -169,6 +171,9 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
if !seen_gpx {
return Err(GpxError::NotGpx);
}
if !path.is_empty() {
return Err(GpxError::Xml("unclosed GPX elements".into()));
}
// stable: points logged at the same second keep their file order
log.points.sort_by(|a, b| a.time.total_cmp(&b.time));
Ok(log)
@@ -362,6 +367,12 @@ mod tests {
fn rejects_non_gpx_and_bad_input() {
assert_eq!(parse_gpx("<kml><Document/></kml>"), Err(GpxError::NotGpx));
assert!(parse_gpx("").is_err());
// A partial logger write must not be mistaken for a complete track,
// even if it contains a valid timed point.
let point = r#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk>"#;
assert!(matches!(parse_gpx(point), Err(GpxError::Xml(_))));
let wrong_close = r#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T00:00:00Z</time></trkpt></trk></trkseg></gpx>"#;
assert!(matches!(parse_gpx(wrong_close), Err(GpxError::Xml(_))));
assert!(
parse_gpx("<gpx><trk><trkseg><trkpt lat=\"x\" lon=\"1\"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>")
.unwrap()
+5
View File
@@ -182,6 +182,11 @@ impl Frame {
pub fn fit(&self, max_w: usize, max_h: usize) -> (usize, usize) {
let a = self.aspect();
let (mw, mh) = (max_w.max(1) as f64, max_h.max(1) as f64);
// a box that is already this aspect up to rounding (an export size) is the size: fitting
// into it again rounds twice and can lose a pixel of the edge that was asked for
if (mw - 0.5) / a <= mh + 0.5 && (mw + 0.5) / a >= mh - 0.5 {
return (max_w.max(1), max_h.max(1));
}
let (w, h) = if mw / mh > a { (mh * a, mh) } else { (mw, mw / a) };
((w.round() as usize).max(1), (h.round() as usize).max(1))
}
+55 -1
View File
@@ -468,6 +468,41 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor
if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() }
}
/// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures.
pub const HDR_HISTOGRAM_EDGE: usize = 384;
/// Long edge of the render that gives a slider drag its live HDR histogram (drafts have none: a
/// second render per tick would slow them down), run beside the drafts by the UI.
pub const HDR_HISTOGRAM_DRAG_EDGE: usize = 192;
/// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR
/// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the
/// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR
/// white, whatever the headroom limit, so the scale stays put) on the right ([`Histogram::of_hdr`]). A preview of
/// an HDR edit shows its SDR rendition; this says how far its highlights go. `None` for SDR edits,
/// high-bit-depth, windowed and proofed requests (their histogram stays the image's).
pub fn hdr_histogram(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Option<Histogram> {
// a drag's drafts keep their own histogram: a second render per tick would slow them down
if !s.hdr.enabled || req.depth != OutputDepth::U8 || req.window.is_some() || req.proof.is_some() || req.quality == Quality::Draft {
return None;
}
let k = (HDR_HISTOGRAM_EDGE as f64 / req.max_w.max(req.max_h).max(1) as f64).min(1.0);
let size = |v: usize| ((v as f64 * k).round() as usize).max(1);
let hreq = RenderRequest {
max_w: size(req.max_w),
max_h: size(req.max_h),
depth: OutputDepth::F32Hdr,
overlay: Overlay::None,
proof: None,
window: None,
display: None,
..*req
};
let deep = render(src, info, s, &hreq).deep?;
let DeepSamples::F32(v) = &deep.samples else { return None };
Some(Histogram::of_hdr(v.as_chunks::<3>().0, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32))
}
/// Render `src` with settings `s`.
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
render_impl(Src::Borrowed(src), info, s, req, None)
@@ -565,7 +600,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
lap("histogram", &mut t);
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
let masks = overlay_alphas_all(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref());
let mut image = if plan.keep.is_some() { cut(&image) } else { image };
if req.overlay == Overlay::HdrRange && edit.hdr.enabled {
// the HDR rendition of the same request says how far above SDR white each pixel goes
@@ -617,6 +653,24 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane>
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
}
/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components,
/// reusing the render's own evaluation where available (a fresh evaluation otherwise).
fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Vec<Plane>> {
if !matches!(o, Overlay::AllMasks { .. }) {
return None;
}
let ev = plan.settings.light.exposure as f32;
let mut out = Vec::new();
for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) {
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
out.push(e.alpha.clone());
} else {
out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()));
}
}
Some(out)
}
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
/// (default look, keeping the crop so framing matches, under the photo's own rendering process).
pub fn before_settings(s: &DevelopSettings) -> DevelopSettings {
+3 -2
View File
@@ -247,8 +247,9 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) {
*d = [v[0], v[1], v[2]];
let (pixels, _) = out.as_chunks::<3>();
for (d, v) in src.iter_mut().zip(pixels) {
*d = *v;
}
}
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more