Compare commits

...
Author SHA1 Message Date
Brandon Thomas 977f912df2 Merge PR #601 (its test fix, already applied above) 2026-10-10 04:35:25 -04:00
Brandon ThomasandClaude Opus 5.5 2ffe2bcea2 LR-BEHAV-BG-TASKS: the preview-build activity test builds small previews (timed out on a loaded machine)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:35:13 -04:00
Brandon Thomas 2bd974d583 Merge PR #590
# Conflicts:
#	crates/engine/src/cmd/previews.rs
#	docs/camera-preview-colour.md
2026-10-10 04:01:27 -04:00
Brandon ThomasandClaude Opus 5.5 4a7eb6b338 LR-IMP-FORMATS: #573's centred crop and #498's DefaultCrop helper side by side (merge put the flag on the wrong function)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:57:57 -04:00
Brandon Thomas 4a25abdfa8 Merge PR #480 2026-10-10 03:47:10 -04:00
Brandon Thomas dd2e916842 Merge PR #555
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-10 03:47:10 -04:00
Brandon Thomas eb2a0fde05 Merge PR #560
# Conflicts:
#	crates/engine/src/media.rs
2026-10-10 03:47:10 -04:00
Brandon ThomasandClaude Opus 5.5 fb6dda44ae LR-IMP-FORMATS: merge main; reconcile camera-TIFF detection with the Samsung/Pentax rules (#281)
Merge origin/main into #480 and apply the review:
- the Samsung-with-a-Pentax-maker-note -> PEF rule now also needs raw evidence; its test fixture
  gets a CFA photometric, and the same RGB file without it is not a raw;
- for the Pentax family the Exif CFAPattern (0xa302) counts as raw evidence, as main's PEF reader
  takes its layout from it (uncompressed / PackBits PEFs);
- Samsung's private raw compressions 32769..=32773 count for SRW (NX1 / NX500 use 32773);
- the engine fallback in files.rs is dropped (main's version kept): a corrupt raw without a
  preview stays an import error; the engine regression test for camera-authored TIFFs is kept.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:15:16 -04:00
Brandon ThomasandClaude Opus 5.5 20d7d9e981 LR-IMP-FORMATS: merge main into the DSLR-A900 packed 12-bit ARW decode
Main's SR2SubIFD white balance (#575) already reads the A900's applied
levels (2688 / 1024 / 1024 / 1516, the same 2.625 / 1.480 gains), so the
hard-coded maker-note 0x0020 block at offset 188 is dropped. The centred
crop now goes through #580's recorded_size_crop (a `centred` flag, only
for the packed 12-bit layout); every other body keeps main's framing.
Adds the CC0 A900 sample to the corpus with checks for its white balance,
black level, bit depth and crop. RENDER_CACHE_VERSION 38.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:54:15 -04:00
Alex ChambersandClaude Opus 5.5 dc37496da4 LR-PROF-CAMERACOLOR: bundled Sony ILCE-7CR camera profile
Fitted with `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131,
September 2025 to September 2026, Standard creative style) and their
in-camera JPEGs; aggregate colour statistics only.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:04:41 -05:00
radialmonster b4c54dbe54 Merge #448 (hasselblad-3fr-fff-main, with main) into hasselblad-p8 (render cache version 23, as #448 has 22)
# Conflicts:
#	crates/engine/src/media.rs
#	docs/parity.md
2026-10-09 12:03:47 -04:00
Jason Baldwin 29b3f35136 LRC-LIB-CATALOG-IMPORT: read Lightroom's Custom white balance by its as-shot shift on relative-WB raws (#510)
A Custom white balance from a Lightroom catalog or XMP sidecar was taken as absolute Kelvin for every
raw. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF; 6500 K
means as shot) then read a small tweak as a large blue shift: ΔE 22 against Lightroom's export on the
photos of issue #510.

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

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

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

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

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

Refs #515

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Regression tests cover both and panicked before the fix.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:00:59 -05:00
radialmonster 2dea9109d8 LR-IMP-FORMATS: name GoPro GPR (VC-5 DNG) in the unsupported-raw error instead of 'TIFF compression 9' 2026-10-09 07:57:30 -04:00
Elliot Graham 41e51a78ef LR-OPT-PROFILES: decode Sony embedded distortion tables 2026-10-09 12:49:48 +01:00
peiyan0 632a455e34 raw: recognize camera-authored TIFFs without CFA as images (#281) 2026-10-09 17:05:25 +08:00
274 changed files with 39912 additions and 2025 deletions

No files matched your search

+3 -3
View File
@@ -26,15 +26,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font build input, so the Japanese font tests run (they skip without it).
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
+6 -1
View File
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -51,6 +51,11 @@ jobs:
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
done
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
+30 -29
View File
@@ -44,7 +44,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -72,17 +72,17 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -102,7 +102,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -130,23 +130,24 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
@@ -164,7 +165,7 @@ jobs:
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -187,9 +188,9 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
@@ -203,8 +204,8 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -228,7 +229,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -250,8 +251,8 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -265,7 +266,7 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@@ -282,16 +283,16 @@ jobs:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@@ -316,7 +317,7 @@ jobs:
bash packaging/freebsd/package.sh
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
@@ -331,17 +332,17 @@ jobs:
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install wasm-bindgen CLI
@@ -359,7 +360,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@@ -378,7 +379,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
+5 -5
View File
@@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@@ -51,7 +51,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: lightcraft-windows-arm64
path: dist/arm64/*
@@ -64,7 +64,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: lightcraft-windows-arm64
path: dist
+7
View File
@@ -55,6 +55,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Pure Rust** in the product. No C/C++ dependencies.
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything
that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind,
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
@@ -103,6 +107,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
`crates/ui-egui/src/menus.rs`.
- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the
Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id
that is unique on screen. Older fields still on `TextEdit` move over as they are touched.
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
Generated
+35 -28
View File
@@ -2976,7 +2976,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -2994,10 +2994,11 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-meta",
"log",
"proptest",
@@ -3009,7 +3010,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3024,8 +3025,9 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"avif-serialize",
"bytemuck",
"image",
"image-webp",
@@ -3039,6 +3041,7 @@ dependencies = [
"moxcms 0.9.1",
"png",
"proptest",
"rav1e",
"ravif",
"rayon",
"serde",
@@ -3050,7 +3053,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3059,7 +3062,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3076,7 +3079,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3091,7 +3094,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3103,7 +3106,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"ab_glyph",
"harfrust",
@@ -3118,6 +3121,7 @@ dependencies = [
"lightcraft-fetch",
"lightcraft-geom",
"lightcraft-gpu",
"lightcraft-heif",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
@@ -3139,7 +3143,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3153,7 +3157,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"rustls",
@@ -3164,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3172,7 +3176,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3181,6 +3185,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
"log",
"pollster 1.0.1",
@@ -3189,14 +3194,15 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"heic-rs",
"rayon",
]
[[package]]
name = "lightcraft-mcp"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3207,7 +3213,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3223,7 +3229,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3235,7 +3241,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3252,7 +3258,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3264,7 +3270,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3276,7 +3282,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3298,7 +3304,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3309,7 +3315,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3322,11 +3328,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.4.0"
version = "0.5.0"
[[package]]
name = "lightcraft-tiff"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3335,7 +3341,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"egui",
"lightcraft-catalog",
@@ -3346,6 +3352,7 @@ dependencies = [
"lightcraft-engine",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
"lightcraft-preview",
@@ -3359,7 +3366,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6819,7 +6826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"brotli",
"flate2",
+4 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -68,6 +68,9 @@ moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
avif-serialize = "0.8.6"
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
+7
View File
@@ -15,6 +15,13 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the
camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data
(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software
Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
+25 -7
View File
@@ -249,6 +249,20 @@ lightcraft --control 7980 ~/Pictures/trip
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
supports cancellation and protects originals and XMP sidecars. From the CLI:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
This is a contact-sheet workflow,
not a printer-driver or custom picture-package layout editor.
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -269,7 +283,8 @@ lightcraft --control 7980 ~/Pictures/trip
- **GPU-accelerated, CPU-exact.** The whole develop pipeline runs as wgpu compute kernels (Metal / Vulkan / DX12),
checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in
~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode.
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects.
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. Batch exports
render several photos side by side, so one's decode and encode overlap another's render.
- **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the
full render follows (~0.2–0.5 s for 24 MP). The next and previous photos are prepared in the background, so stepping
through a shoot takes ~50 ms per photo.
@@ -289,10 +304,10 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
@@ -308,7 +323,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -316,7 +332,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| AI: segmentation masks, AI denoise, super resolution, faces; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
@@ -354,7 +370,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
@@ -444,7 +460,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
### macOS
+28 -21
View File
@@ -23,20 +23,20 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); embedded distortion is supported for Panasonic / Leica RW2 and the Sony ILCE-7RM4A (Bayer ARW, 24–105 / 200–600 tested); other lens metadata needs coverage and validation |
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
@@ -48,8 +48,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
checked on the corpus); then ORF, then validate fidelity.
Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF
since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to
measured spectral sensitivities (rawtoaces-data); then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
@@ -57,18 +58,24 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
checked for plausible colour; fix per-model bugs (#85).
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
(references stay in the local `plan/`), then tune against the numbers.
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW
embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata.
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour
labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a
problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is
the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows.
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
@@ -89,13 +96,13 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
@@ -164,7 +171,8 @@ The milestone estimates in the table above were made before work started and are
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
Laplacian filters, PatchMatch and HEVC (HEIC).
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
turns it into measured comparisons against local-only Lightroom references.
turns it into measured comparisons against local-only Lightroom references. Retuning changes existing edits, so it
ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated.
## Raw format coverage and known gaps
@@ -175,20 +183,16 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted
`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
@@ -220,13 +224,16 @@ database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-cat
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos. On 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open.
## Chinese and Japanese interfaces, and text watermarks
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels
and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English.
CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
+5 -5
View File
@@ -391,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
}
i += 1;
}
let mut s = Session::new().with_fs().with_default_denoise_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let paths = expand_paths(&files);
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
@@ -652,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> {
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
let mut s = Session::new().with_fs().with_default_denoise_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let abs = expand_paths(std::slice::from_ref(&input));
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
@@ -732,12 +732,12 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs().with_default_denoise_models().with_default_face_models();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_default_face_models(),
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
+42 -5
View File
@@ -7,6 +7,46 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
#[test]
fn contact_sheet_exports_pdf_and_protects_originals() {
let input = tmp("contact-sheet-input.png");
gradient_png(&input);
let out = tmp("contact-sheet.pdf");
let (ok, lines, err) = run_cli(
&[
"--import",
input.to_str().unwrap(),
"export.contactSheet",
&format!("path={}", out.display()),
"paper=letter",
"landscape=true",
"columns=2",
"rows=3",
],
None,
);
assert!(ok, "{err}: {lines:?}");
assert_eq!(lines[0]["result"]["pages"], 1);
assert_eq!(lines[0]["result"]["photos"], 1);
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]"));
let before = std::fs::read(&input).unwrap();
for target in [input.clone(), input.with_extension("xmp")] {
if target != input {
std::fs::write(&target, b"existing sidecar").unwrap();
}
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None);
assert!(!ok, "overwrote protected file");
}
assert_eq!(std::fs::read(&input).unwrap(), before);
assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar");
let prior = std::fs::read(&out).unwrap();
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None);
assert!(!ok);
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
@@ -236,11 +276,8 @@ fn snapshot_script_failure_exits_non_zero() {
#[test]
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
let script = tmp("snap-ui-zoom.jsonl");
std::fs::write(
&script,
format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
)
.unwrap();
std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"})))
.unwrap();
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
let o = Command::new(BIN)
+3
View File
@@ -169,6 +169,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
let restore_backend = backend.clone();
Services {
picker: None,
pick_display_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
@@ -213,6 +214,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
// Face models need a folder to live in; the web has none.
pick_model_file: None,
save_preset_file: None,
pick_keyword_list: None,
save_keyword_list: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
write: Some(Box::new(download)),
+1
View File
@@ -113,6 +113,7 @@ impl WireJob {
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
display: None,
}
}
+3
View File
@@ -293,6 +293,7 @@ impl Workers {
rendered: Err(msg),
loaded: None,
quick: None,
display: None,
};
g.done.push((b.slot, r, 0.0));
}
@@ -379,6 +380,7 @@ impl Workers {
rendered,
loaded: None,
quick: None,
display: None,
},
ms,
));
@@ -431,6 +433,7 @@ impl RenderOffload for Workers {
rendered: Ok(Rendered { image, histogram, deep: None }),
loaded: None,
quick: None,
display: None,
};
g.done.push((slot, r, 0.0));
return None;
+50
View File
@@ -78,9 +78,17 @@ impl eframe::App for App {
if let Err(e) = self.0.session.close_library() {
log::error!("saving the library failed: {e}");
}
// last, with everything saved: no render may be inside the GPU driver when the process ends
if !self.0.shutdown(EXIT_WAIT) {
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
}
}
}
/// How long quitting waits for renders that are running (issue #620). A GPU render takes well under
/// a second; anything slower gives way to quitting.
const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
@@ -133,6 +141,23 @@ fn gpu_crash_notice(what: &str) -> String {
)
}
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
fn display_profile_dir() -> Option<std::path::PathBuf> {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
let candidates: Vec<Option<std::path::PathBuf>> = if cfg!(target_os = "macos") {
vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
} else if cfg!(windows) {
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
} else {
let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
};
candidates.into_iter().flatten().find(|d| d.is_dir())
}
fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
}
@@ -464,6 +489,15 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
pick_display_profile: Some(Box::new(|| {
let mut d = rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
if let Some(dir) = display_profile_dir() {
d = d.set_directory(dir);
}
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
})),
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
@@ -472,6 +506,22 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_keyword_list: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
+4
View File
@@ -12,7 +12,10 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
@@ -22,6 +25,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
{
"description": "Sony's global tone curve with the Dynamic Range Optimizer (DRO) off, Standard creative style: knots of scene luminance (white-balanced camera RGB with baseline exposure, through the colour model) to display-linear luminance. The camera look of an ARW shot with DRO on is lowered to it wherever it is darker (crates/engine/src/camera_preview.rs, docs/camera-preview-colour.md, issue #244).",
"derivation": "Each source raw was paired with its own embedded camera JPEG at the 96-pixel proxy of the per-photo fit (camera_preview::collect_pairs). The pairs of all 12 files were pooled as one model, as lightcraft-cli calibrate pools a camera profile: a colour matrix and hue/saturation table were fitted to the pool, and the tone curve was quantile-matched (camera_preview::fit_tone) between the pooled scene luminances through that colour model and the JPEG luminances. Knots rounded to 5 decimals. Aggregate statistics only, no image content.",
"sources": [
{ "body": "ILCA-77M2", "url": "https://raw.pixls.us/data/Sony/ILCA-77M2/_DSC0244.ARW", "sha256": "f41ceff680b675460cc1fe258a24d8c8c01c52a300933f3ab6401d9a20b60dcc", "licence": "CC0" },
{ "body": "ILCE-3500", "url": "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", "sha256": "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", "licence": "CC0" },
{ "body": "ILCE-5000", "url": "https://raw.pixls.us/data/Sony/ILCE-5000/_DSC0793.ARW", "sha256": "a919c8535eed68577f1745fb9674ccf333962628ccdec6d48a6f635208467f6c", "licence": "CC0" },
{ "body": "ILCE-6600", "url": "https://raw.pixls.us/data/Sony/ILCE-6600/_DSC0268.ARW", "sha256": "041d71d70148ca77164f19d061e3582adb34399ea5a4baba32960f2956fb0362", "licence": "CC0" },
{ "body": "ILCE-7CR", "url": "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", "sha256": "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", "licence": "CC0" },
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/UNCOMPRESSED_14bit.ARW", "sha256": "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", "licence": "CC0" },
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0009.ARW", "sha256": "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", "licence": "CC0" },
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0010.ARW", "sha256": "3ae2ff75cfa7240d0b4458f662afd28665c5fd1e83d230ac73f5ad69d3df8592", "licence": "CC0" },
{ "body": "ILCE-7S", "url": "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", "sha256": "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", "licence": "CC0" },
{ "body": "ILCE-9M2", "url": "https://raw.pixls.us/data/Sony/ILCE-9M2/SONY_A9II_(ILCE-9M2)_-_compressed_(3:2)_14bit.ARW", "sha256": "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", "licence": "CC0" },
{ "body": "NEX-F3", "url": "https://raw.pixls.us/data/Sony/NEX-F3/_DSC4017.ARW", "sha256": "cfdc60d118b5ce2dce10d459dcb98ad63c6db3e609999932ca8964d86daf836b", "licence": "CC0" },
{ "body": "SLT-A57", "url": "https://raw.pixls.us/data/Sony/SLT-A57/a57-06125.arw", "sha256": "0e9064f369a3947f892c1d7b1971f75d3879f5673134932d5cc7a67bf4263ba4", "licence": "CC0" }
],
"knots": [
[0.01655, 0.01799],
[0.01970, 0.02376],
[0.02212, 0.03008],
[0.02469, 0.03634],
[0.02737, 0.04320],
[0.03049, 0.05044],
[0.03425, 0.05812],
[0.03824, 0.06667],
[0.04232, 0.07817],
[0.04658, 0.09059],
[0.05095, 0.10268],
[0.05550, 0.11731],
[0.05962, 0.13173],
[0.06409, 0.14529],
[0.06843, 0.15939],
[0.07314, 0.17363],
[0.07800, 0.18897],
[0.08298, 0.20502],
[0.08909, 0.22159],
[0.09693, 0.23964],
[0.10540, 0.26082],
[0.11579, 0.28882],
[0.12763, 0.31986],
[0.14079, 0.35198],
[0.15786, 0.39293],
[0.17949, 0.44101],
[0.20330, 0.49407],
[0.23466, 0.54244],
[0.27207, 0.60078],
[0.31673, 0.66533],
[0.38682, 0.74143],
[0.52622, 0.80307]
]
}
+666 -62
View File
@@ -2,23 +2,23 @@
"schema": 1,
"repo": "storytold/lightcraft",
"branch": "main",
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
"head_date": "2026-10-08T00:53:19Z",
"commits": 424,
"head": "c545cc5e842a74d65f32187d9fdee86ada3099de",
"head_date": "2026-10-10T05:40:18Z",
"commits": 1033,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 71,
"commits": 330,
"lines_added": 125623,
"lines_deleted": 8643,
"prs": 85,
"commits": 370,
"lines_added": 131038,
"lines_deleted": 9013,
"binary_added": 38,
"binary_deleted": 8,
"first_commit": "2026-09-30T14:04:12Z",
"last_commit": "2026-10-08T00:32:34Z"
"last_commit": "2026-10-10T05:56:02Z"
},
{
"login": "leipsfur",
@@ -50,40 +50,53 @@
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 1194,
"lines_deleted": 82,
"prs": 8,
"commits": 9,
"lines_added": 1925,
"lines_deleted": 119,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T00:28:49Z",
"last_commit": "2026-10-06T06:51:43Z"
"last_commit": "2026-10-09T06:02:21Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 424,
"lines_deleted": 25,
"prs": 6,
"commits": 6,
"lines_added": 884,
"lines_deleted": 93,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:07:26Z",
"last_commit": "2026-10-05T16:07:26Z"
"last_commit": "2026-10-09T18:24:49Z"
},
{
"login": "voshart",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 12,
"lines_added": 2481,
"lines_deleted": 1389,
"prs": 8,
"commits": 26,
"lines_added": 30524,
"lines_deleted": 4416,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:23:27Z",
"last_commit": "2026-10-07T16:07:24Z"
"last_commit": "2026-10-09T02:31:04Z"
},
{
"login": "akvaithi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 5,
"lines_added": 1799,
"lines_deleted": 114,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:53:56Z",
"last_commit": "2026-10-05T19:55:59Z"
},
{
"login": "arata-1972",
@@ -124,6 +137,19 @@
"first_commit": "2026-10-06T20:21:14Z",
"last_commit": "2026-10-06T20:21:14Z"
},
{
"login": "Exalted5373",
"people_entry": false,
"github_name": null,
"prs": 9,
"commits": 15,
"lines_added": 4316,
"lines_deleted": 309,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T21:01:23Z",
"last_commit": "2026-10-09T21:58:32Z"
},
{
"login": "treyreynolds",
"people_entry": false,
@@ -193,7 +219,7 @@
"login": "itsmunzir",
"people_entry": false,
"github_name": null,
"prs": 1,
"prs": 2,
"commits": 1,
"lines_added": 46,
"lines_deleted": 30,
@@ -245,40 +271,40 @@
"login": "radialmonster",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 19,
"lines_added": 2630,
"lines_deleted": 259,
"prs": 35,
"commits": 64,
"lines_added": 8732,
"lines_deleted": 822,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T12:33:51Z",
"last_commit": "2026-10-08T00:46:10Z"
"last_commit": "2026-10-10T02:01:36Z"
},
{
"login": "bogusyogi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 434,
"lines_deleted": 29,
"prs": 2,
"commits": 17,
"lines_added": 5124,
"lines_deleted": 603,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:06:00Z",
"last_commit": "2026-10-07T17:06:00Z"
"last_commit": "2026-10-08T19:26:28Z"
},
{
"login": "nledford",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 8,
"lines_added": 525,
"lines_deleted": 60,
"prs": 19,
"commits": 305,
"lines_added": 24796,
"lines_deleted": 2896,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:33:38Z",
"last_commit": "2026-10-07T22:49:00Z"
"last_commit": "2026-10-09T23:05:22Z"
},
{
"login": "MusseJusse",
@@ -336,14 +362,27 @@
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 54,
"lines_deleted": 12,
"prs": 3,
"commits": 2,
"lines_added": 206,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:08:01Z",
"last_commit": "2026-10-07T22:08:01Z"
"last_commit": "2026-10-08T17:04:19Z"
},
{
"login": "sebastianpfluegelmeier",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 4,
"lines_added": 23063,
"lines_deleted": 17968,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:14:20Z",
"last_commit": "2026-10-08T14:38:56Z"
},
{
"login": "agorevski",
@@ -371,6 +410,487 @@
"first_commit": "2026-10-08T00:53:19Z",
"last_commit": "2026-10-08T00:53:19Z"
},
{
"login": "Hanmo123",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 6,
"lines_added": 922,
"lines_deleted": 84,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T02:16:44Z",
"last_commit": "2026-10-09T05:35:02Z"
},
{
"login": "wondarkus",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 8,
"lines_added": 2776,
"lines_deleted": 1387,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T02:56:08Z",
"last_commit": "2026-10-08T04:23:30Z"
},
{
"login": "xak47d",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 58,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T04:10:21Z",
"last_commit": "2026-10-08T04:10:21Z"
},
{
"login": "marceldenzer",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2864,
"lines_deleted": 261,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T05:27:32Z",
"last_commit": "2026-10-08T05:27:32Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 25,
"commits": 2,
"lines_added": 108,
"lines_deleted": 24,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T06:30:19Z",
"last_commit": "2026-10-08T14:00:00Z"
},
{
"login": "wstock",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 8,
"lines_added": 834,
"lines_deleted": 67,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T07:45:39Z",
"last_commit": "2026-10-08T11:55:57Z"
},
{
"login": "marek-hubar",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 212,
"lines_deleted": 29,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T09:01:44Z",
"last_commit": "2026-10-08T09:01:44Z"
},
{
"login": "switch87",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 762,
"lines_deleted": 34,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T11:00:29Z",
"last_commit": "2026-10-08T11:00:29Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 50,
"lines_deleted": 50,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:12:53Z",
"last_commit": "2026-10-08T14:01:45Z"
},
{
"login": "Issuenate",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 107,
"lines_deleted": 12,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:26:27Z",
"last_commit": "2026-10-08T12:28:18Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 231,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 7,
"lines_added": 351,
"lines_deleted": 36,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:44:47Z",
"last_commit": "2026-10-09T17:29:39Z"
},
{
"login": "peiyan0",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 215,
"lines_deleted": 80,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T15:36:56Z",
"last_commit": "2026-10-09T08:09:23Z"
},
{
"login": "hemna",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 459,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T16:45:33Z",
"last_commit": "2026-10-08T16:45:33Z"
},
{
"login": "joshuaswarren",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 349,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T16:47:59Z",
"last_commit": "2026-10-08T16:47:59Z"
},
{
"login": "wrgdbtry",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2733,
"lines_deleted": 63,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T19:22:13Z",
"last_commit": "2026-10-08T19:22:13Z"
},
{
"login": "parkerdotexe",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 1,
"lines_added": 712,
"lines_deleted": 84,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T19:54:50Z",
"last_commit": "2026-10-08T19:54:50Z"
},
{
"login": "ragtux",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 805,
"lines_deleted": 50,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T22:50:18Z",
"last_commit": "2026-10-08T23:42:19Z"
},
{
"login": "sanyamsingh001",
"people_entry": true,
"github_name": null,
"prs": 4,
"commits": 5,
"lines_added": 841,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T03:19:47Z",
"last_commit": "2026-10-10T01:46:07Z"
},
{
"login": "onemannoplan",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T04:53:21Z",
"last_commit": "2026-10-09T04:53:21Z"
},
{
"login": "Yijie-Si",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 1,
"lines_added": 267,
"lines_deleted": 43,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T06:48:00Z",
"last_commit": "2026-10-09T06:48:00Z"
},
{
"login": "HarrisXiu",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 6,
"lines_added": 1247,
"lines_deleted": 80,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T09:34:51Z",
"last_commit": "2026-10-10T05:46:24Z"
},
{
"login": "rock-meister",
"people_entry": true,
"github_name": "rock-meister",
"prs": 4,
"commits": 5,
"lines_added": 291,
"lines_deleted": 46,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T10:15:52Z",
"last_commit": "2026-10-09T15:00:07Z"
},
{
"login": "QuirkyTurtle94",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 475,
"lines_deleted": 77,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T11:08:11Z",
"last_commit": "2026-10-09T14:32:04Z"
},
{
"login": "AER00",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 207,
"lines_deleted": 21,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:48:11Z",
"last_commit": "2026-10-09T13:57:38Z"
},
{
"login": "AlexChambers",
"people_entry": false,
"github_name": null,
"prs": 11,
"commits": 16,
"lines_added": 7202,
"lines_deleted": 954,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:59:07Z",
"last_commit": "2026-10-10T05:40:18Z"
},
{
"login": "bitshiftza",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 342,
"lines_deleted": 97,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T15:03:06Z",
"last_commit": "2026-10-09T18:14:49Z"
},
{
"login": "vbasky",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 622,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:43:31Z",
"last_commit": "2026-10-09T17:43:31Z"
},
{
"login": "kelanna",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1169,
"lines_deleted": 24,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T17:51:06Z",
"last_commit": "2026-10-09T17:51:06Z"
},
{
"login": "dmatviichuk",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2292,
"lines_deleted": 44,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T18:02:42Z",
"last_commit": "2026-10-09T18:02:42Z"
},
{
"login": "GarrettGR",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 5,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T19:37:15Z",
"last_commit": "2026-10-09T19:37:15Z"
},
{
"login": "dylanmtaylor",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 38,
"lines_deleted": 44,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:04:24Z",
"last_commit": "2026-10-10T00:04:24Z"
},
{
"login": "sawizzle",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 23,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:06:24Z",
"last_commit": "2026-10-10T04:50:51Z"
},
{
"login": "KitsuneSemCalda",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 11,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T01:50:45Z",
"last_commit": "2026-10-10T01:50:45Z"
},
{
"login": "BuonBreda",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "cookingwithsimon",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "enzoflayac",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "GuintherKovalski",
"people_entry": false,
@@ -384,6 +904,45 @@
"first_commit": null,
"last_commit": null
},
{
"login": "henu043",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "pikkemaat",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "stano-bustor",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "XRTce",
"people_entry": false,
@@ -404,36 +963,45 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 364,
"lines_added": 131810,
"lines_deleted": 9367
"commits": 747,
"lines_added": 178263,
"lines_deleted": 14199
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 31,
"lines_added": 3941,
"lines_deleted": 1543
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 14,
"lines_added": 8341,
"lines_deleted": 995
"commits": 116,
"lines_added": 15071,
"lines_deleted": 2624
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 2,
"lines_added": 1304,
"lines_deleted": 192
"commits": 66,
"lines_added": 8450,
"lines_deleted": 956
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 38,
"lines_added": 17120,
"lines_deleted": 1703
},
{
"id": "anthropic/claude-fable/5.1",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 9,
"lines_added": 1548,
"lines_deleted": 112
},
{
"id": "anthropic/claude-sonnet/5",
@@ -453,6 +1021,15 @@
"lines_added": 1633,
"lines_deleted": 3
},
{
"id": "deepseek/deepseek-flash/v4",
"company": "DeepSeek",
"model": "DeepSeek Flash",
"version": "V4",
"commits": 1,
"lines_added": 1439,
"lines_deleted": 6
},
{
"id": "deepseek/deepseek/v3.2",
"company": "DeepSeek",
@@ -461,6 +1038,33 @@
"commits": 1,
"lines_added": 1895,
"lines_deleted": 294
},
{
"id": "openai/gpt-6-luna",
"company": "OpenAI",
"model": "GPT-6 Luna",
"version": "",
"commits": 1,
"lines_added": 68,
"lines_deleted": 19
},
{
"id": "openai/gpt/6",
"company": "OpenAI",
"model": "GPT",
"version": "6",
"commits": 1,
"lines_added": 2864,
"lines_deleted": 261
},
{
"id": "opencode/opencode",
"company": "opencode",
"model": "opencode",
"version": "",
"commits": 1,
"lines_added": 459,
"lines_deleted": 5
}
]
}
+1
View File
@@ -19,4 +19,5 @@ log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
lightcraft-geom = { workspace = true }
proptest = { workspace = true }
+17 -3
View File
@@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
/// A date without a time gets one. `None` if it doesn't parse.
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
/// anything, it never overflows.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
let t = iso_seconds(iso)?;
let t = iso_seconds(iso)?.checked_add(secs)?;
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
if !(first..=last).contains(&t) {
return None;
}
let tail = iso.trim().get(19..).unwrap_or("");
Some(format!("{}{tail}", civil(t + secs)))
Some(format!("{}{tail}", civil(t)))
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
@@ -204,6 +210,14 @@ mod tests {
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
// hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date
// here can read back) is refused rather than stored
for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] {
assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}");
}
assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None);
assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None);
assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59"));
assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00"));
assert_eq!(normalize_iso("2026-02-30"), None);
}
+95 -7
View File
@@ -23,15 +23,21 @@
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
//!
//! A folder is otherwise only where photos are, but the library can also keep a
//! [`FolderRecord`] about it (its colour label), stored under the folder's identity
//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved
//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is
//! kept (undo can bring the photos back) and shows again when the folder does.
//!
//! This is the library's own view of its photos; browsing any folder on disk without importing
//! is Local's job (see [`crate::local`]).
use std::collections::{BTreeMap, HashMap};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::query::{folder_key, folder_within, split_path};
use crate::{Catalog, Source};
use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path};
use crate::{Catalog, ColorLabel, Op, Source};
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
@@ -58,9 +64,35 @@ pub struct FolderNode {
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
/// path covers those disks' photos too, so choosing them is not offered.
pub selectable: bool,
/// The folder's colour label (see [`FolderRecord`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<ColorLabel>,
pub children: Vec<FolderNode>,
}
/// What the library keeps about one of its folders beyond the photos in it. A record with
/// nothing in it is not kept.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FolderRecord {
/// A colour label, as a photo has one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<ColorLabel>,
}
impl FolderRecord {
/// Whether the record says nothing (and so is not kept).
pub fn is_empty(&self) -> bool {
self.label.is_none()
}
}
/// The identity a folder's record is kept under: its [`folder_key`]; `None` for a path that
/// names no folder (empty, relative, `.`).
pub(crate) fn record_key(path: &str) -> Option<String> {
let key = folder_key(path);
(split_path(path, true).absolute && !key.is_empty()).then_some(key)
}
/// A folder while the tree is being built (children by identity key).
struct Level {
name: String,
@@ -151,6 +183,53 @@ pub fn folder_label(path: &str) -> String {
}
impl Catalog {
/// What the library keeps about folder `path`, however it is spelled.
pub fn folder_record(&self, path: &str) -> Option<&FolderRecord> {
record_key(path).and_then(|k| self.folder_records.get(&k))
}
/// The colour label of folder `path`, however it is spelled.
pub fn folder_color_label(&self, path: &str) -> Option<ColorLabel> {
self.folder_record(path).and_then(|r| r.label)
}
/// The op that gives folder `path` colour label `label` (`None`: takes it off), keeping the
/// rest of its record. Applying it checks the path.
pub fn folder_label_op(&self, path: &str, label: Option<ColorLabel>) -> Op {
let mut record = self.folder_record(path).cloned().unwrap_or_default();
record.label = label;
Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
}
/// The ops that carry the records of folder `from` and the folders inside it over to `to`
/// (the same place below it), for a folder renamed or moved on disk: commit them with the
/// photos' relinks so undo puts both back. Records already at or below `to` are left from a
/// folder that is no longer there and are dropped (undo restores them). Empty when there is
/// nothing to carry or drop.
pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec<Op> {
let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
if root == dest {
return Vec::new();
}
let moved: Vec<(String, FolderRecord)> = self
.folder_records
.iter()
.filter(|(k, _)| key_within(k, &root))
.filter_map(|(k, r)| {
let rest = folder_rest(k, &root)?;
let base = dest.trim_end_matches('/');
let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) };
Some((folder_key(&path), r.clone()))
})
.collect();
// what was known at the old place goes, and so does what is left at the new one (also
// when the moved folder carries nothing: the folder now there is not the one it was about)
let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest));
let mut ops: Vec<Op> = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect();
ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) }));
ops
}
/// The library's volumes with their folders and photo counts: volumes by name
/// (case-insensitive), each folder with its subfolders the same way.
pub fn folder_tree(&self) -> Vec<FolderNode> {
@@ -196,7 +275,7 @@ impl Catalog {
l.own += n;
}
}
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect();
sort(&mut tree);
// a folder that holds another disk's mount covers that disk's photos too
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
@@ -206,12 +285,21 @@ impl Catalog {
}
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
fn build(key: &str, levels: &HashMap<String, Level>, records: &BTreeMap<String, FolderRecord>) -> Option<FolderNode> {
let l = levels.get(key)?;
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels, records)).collect();
sort(&mut children);
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
Some(FolderNode {
name: l.name.clone(),
path: l.path.clone(),
count,
own: l.own,
volume: l.volume,
selectable: true,
label: records.get(key).and_then(|r| r.label),
children,
})
}
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
+4 -1
View File
@@ -46,6 +46,9 @@
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -70,7 +73,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 3;
pub const VERSION: u32 = 6;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
File diff suppressed because it is too large. Load diff
+117 -3
View File
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
pub use folders::FolderNode;
pub use folders::{FolderNode, FolderRecord};
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
@@ -52,6 +52,9 @@ pub enum CatalogError {
NoStack(StackId),
#[error("invalid: {0}")]
Invalid(String),
/// A keyword of that name is there already: moving or renaming onto it would merge the two.
#[error("there is a keyword “{0}” already")]
KeywordExists(String),
#[error("corrupt catalog data: {0}")]
Corrupt(String),
#[error("catalog storage: {0}")]
@@ -206,6 +209,11 @@ pub enum Op {
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
},
/// The lens data a photo's file carries (what Reload finds when it was read after import).
SetEmbeddedLens {
id: PhotoId,
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
@@ -217,6 +225,18 @@ pub enum Op {
folder: String,
at: Option<String>,
},
/// List a keyword in the library's keyword list with these attributes, or take it off the list
/// (`None`; photos that carry it keep it). Format version 4.
SetKeyword {
path: String,
info: Option<keywords::KeywordInfo>,
},
/// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under
/// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing.
SetFolderRecord {
folder: String,
record: Option<FolderRecord>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@@ -239,6 +259,12 @@ pub struct Catalog {
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap<String, String>,
/// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
keyword_list: BTreeMap<String, keywords::ListedKeyword>,
/// What the library keeps about its folders (folder identity → record), see [`folders`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
folder_records: BTreeMap<String, FolderRecord>,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@@ -324,12 +350,57 @@ impl Catalog {
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self),
// guarded: a smart album testing smart albums can't loop or recurse without end
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
Some(a) => a.photos.contains(&p.id),
None => false,
}
}
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
/// search, they don't repeat it.
pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool {
let mut seen: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
let mut next = vec![from];
while let Some(a) = next.pop() {
if !seen.insert(a) {
continue;
}
let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
if tested.contains(&to) {
return true;
}
next.extend(tested);
}
false
}
/// What is wrong with a saved smart album's rules now (`RuleSet::check`, after
/// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty
/// for a sound smart album, a plain album or no album.
pub fn smart_album_problems(&self, id: AlbumId) -> Vec<rules::Problem> {
let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
let mut out: Vec<rules::Problem> = self.album_filter_problem(filter, Some(id)).into_iter().collect();
if let Some(mut rules) = filter.rule_set.clone() {
rules.upgrade();
out.extend(rules.check_for(self, Some(id)));
}
out
}
/// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to
/// itself, or to an album that leads back to it.
pub fn album_filter_problem(&self, filter: &Filter, owner: Option<AlbumId>) -> Option<rules::Problem> {
let (a, owner) = (filter.album?, owner?);
(a == owner || self.album_reaches(a, owner)).then(|| rules::Problem {
path: Vec::new(),
field: Some("album".into()),
issue: rules::Issue::AlbumLoop,
message: format!("its album filter (album {}) would make this album include itself", a.0),
})
}
/// The photos of an album: the stored list, or a smart album's current matches (id order).
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
match self.albums.get(&id) {
@@ -366,6 +437,18 @@ impl Catalog {
pub fn label_name(&self, l: ColorLabel) -> String {
self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}"))
}
/// A listed keyword's attributes (any case), `None` when it isn't listed.
pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info)
}
/// How the keyword list spells a listed keyword (any case), `None` when it isn't listed.
pub fn listed_path(&self, path: &str) -> Option<&str> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str())
}
/// The keyword list: keywords listed on their own or given attributes, by path.
pub fn listed_keywords(&self) -> impl Iterator<Item = &keywords::ListedKeyword> {
self.keyword_list.values()
}
/// The custom name of a colour label, if any.
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
@@ -373,7 +456,9 @@ impl Catalog {
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
let name = name.trim();
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
// any case, accents included (Été / été), as rules compare names
let name_lower = name.to_lowercase();
self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
@@ -596,6 +681,10 @@ impl Catalog {
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
}
}
Op::SetEmbeddedLens { id, lens } => {
let p = self.photo_mut(id)?;
Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
return Err(CatalogError::Invalid("empty file name".into()));
@@ -612,6 +701,21 @@ impl Catalog {
};
Op::SetLabelName { label, name: old }
}
Op::SetKeyword { path, info } => {
let path = keywords::clean(&path);
if path.is_empty() {
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
}
let key = path.to_lowercase();
let old = match info {
Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }),
None => self.keyword_list.remove(&key),
};
match old {
Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) },
None => Op::SetKeyword { path, info: None },
}
}
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
@@ -620,6 +724,14 @@ impl Catalog {
};
Op::SetBrowsed { folder, at: old }
}
Op::SetFolderRecord { folder, record } => {
let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?;
let old = match record.filter(|r| !r.is_empty()) {
Some(r) => self.folder_records.insert(key.clone(), r),
None => self.folder_records.remove(&key),
};
Op::SetFolderRecord { folder: key, record: old }
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
@@ -721,6 +833,8 @@ mod tests_album_order;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_folder_records;
#[cfg(test)]
mod tests_folders;
#[cfg(test)]
mod tests_format_version;
+5 -2
View File
@@ -283,7 +283,8 @@ impl Op {
| Op::SetAnalysis { id, .. }
| Op::SetFile { id, .. }
| Op::Relink { id, .. }
| Op::SetContent { id, .. } => out(*id),
| Op::SetContent { id, .. }
| Op::SetEmbeddedLens { id, .. } => out(*id),
Op::AddAlbum { album } => {
album.photos.iter().for_each(|p| out(*p));
album.cover.into_iter().for_each(&mut *out);
@@ -299,7 +300,9 @@ impl Op {
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. } => {}
| Op::SetKeyword { .. }
| Op::SetBrowsed { .. }
| Op::SetFolderRecord { .. } => {}
}
}
}
+98 -6
View File
@@ -152,6 +152,17 @@ impl CopyrightStatus {
}
}
/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for
/// anything that isn't a positive, finite time.
pub fn parse_shutter_seconds(s: &str) -> Option<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
None => s.parse().ok(),
}
.filter(|v: &f64| v.is_finite() && *v > 0.0)
}
/// Descriptive + capture metadata.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -296,6 +307,14 @@ pub struct Analysis {
pub best: bool,
}
/// Raw formats whose readers have vendor white-balance multipliers but no measured camera
/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means
/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's
/// `fileFormat` name, in any case. See [`Photo::relative_wb`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
impl Photo {
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
@@ -336,24 +355,27 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
self.develops_raw()
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
self.develops_raw() && relative_wb_format(&self.format)
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
/// in the photo's own rendering process.
pub fn import_defaults(&self) -> DevelopSettings {
match &self.import_look {
Some(l) => (**l).clone(),
Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() },
None => self.camera_defaults(),
}
}
/// The built-in defaults for this photo, before any user default preset: raws start from
/// their as-shot white balance; embedded lens corrections on when the file has them.
/// their as-shot white balance; embedded lens corrections on when the file has them. They
/// carry the photo's own rendering process ([`lightcraft_develop::ProcessVersion`]): which
/// process a photo renders with is not an edit, so comparisons with its defaults (edited?
/// still as imported?) don't change when a newer process exists. A new photo has the latest.
pub fn camera_defaults(&self) -> DevelopSettings {
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
let mut d = match wb {
@@ -363,8 +385,49 @@ impl Photo {
if self.embedded_lens.is_some() {
d.optics.lens_profile = true;
}
d.process = self.develop.process;
d
}
/// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty
/// when the name has none (`README`, a dot file such as `.hidden`).
pub fn extension(&self) -> &str {
self.file_name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()).map_or("", |(_, ext)| ext)
}
/// Width and height in pixels as the photo is shown: turned by its orientation and cut by its
/// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the
/// aspect is exact and the edges can read a little larger than an export.
pub fn shown_size(&self) -> (f64, f64) {
let (w, h) = (self.width as f64, self.height as f64);
let (w, h) = if self.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
let r = self.develop.crop.geometry.rect;
// a crop read from a file or an agent is hostile: no NaN, nothing outside the frame
let part = |len: f64| if len.is_finite() { len.clamp(0.0, 1.0) } else { 1.0 };
(w * part(r.width()), h * part(r.height()))
}
/// Cropped or straightened.
pub fn is_cropped(&self) -> bool {
!self.develop.crop.geometry.is_identity()
}
/// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and
/// the same keyword in different case or with stray spaces counts once; blank ones don't count.
pub fn keyword_count(&self) -> usize {
let mut seen: Vec<String> = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect();
seen.sort_unstable();
seen.dedup();
seen.len()
}
/// The people in the photo: the names on its face regions, trimmed, each once (names that
/// differ only in case are one person, as first seen). Pets and unnamed faces are not people.
pub fn people(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for r in self.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) {
out.push(name);
}
}
out
}
/// In the library: not deleted and not only browsed (Local).
pub fn in_library(&self) -> bool {
!self.deleted && !self.local
@@ -439,6 +502,32 @@ mod edited_tests {
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn the_process_a_photo_renders_with_is_not_an_edit() {
use lightcraft_develop::ProcessVersion;
// a photo on another process than the latest (a V1 photo once a later process is the
// latest, or a photo saved by a newer LightCraft)
let other = ProcessVersion(ProcessVersion::LATEST.0 + 1);
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.dng", "DNG", 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.camera_defaults().process, ProcessVersion::LATEST, "a new photo gets the latest");
p.develop = Arc::new(DevelopSettings { process: other, ..p.import_defaults() });
assert_eq!((p.camera_defaults().process, p.import_defaults().process), (other, other));
assert!(!p.is_edited(), "still as imported");
// with a default preset's look as well
let mut look = DevelopSettings::for_raw(5200.0, 4.0);
look.light.exposure = 0.3;
p.import_look = Some(Arc::new(look.clone()));
p.develop = Arc::new(DevelopSettings { process: other, ..look });
assert_eq!(p.import_defaults().process, other);
assert!(!p.is_edited());
let mut d = (*p.develop).clone();
d.light.exposure = 1.0;
p.develop = Arc::new(d);
assert!(p.is_edited(), "an edit still is one");
}
#[test]
fn supported_raws_use_relative_white_balance() {
for (name, format, relative) in [
@@ -451,6 +540,9 @@ mod edited_tests {
("a.raf", "rAf", true),
("a.cr3", "cr3", true),
("a.srw", "SRW", true),
("a.cr2", "CR2", true),
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
+18 -2
View File
@@ -154,12 +154,21 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
/// Whether `p` has a named face region called `name` (case-insensitive).
fn has_person(p: &Photo, name: &str) -> bool {
let name = name.trim().to_lowercase();
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
p.people().iter().any(|n| n.to_lowercase() == name)
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
self.describe_in(None)
}
/// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`).
pub fn describe_with(&self, cat: &crate::Catalog) -> String {
self.describe_in(Some(cat))
}
fn describe_in(&self, cat: Option<&crate::Catalog>) -> String {
let mut v: Vec<String> = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
@@ -207,11 +216,18 @@ impl Filter {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
v.push(rs.describe());
v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c)));
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
/// The albums this filter tests: its album field and its rules' Album rules.
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
let mut out: Vec<crate::AlbumId> = self.album.into_iter().collect();
out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default());
out
}
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
File diff suppressed because it is too large. Load diff
+172
View File
@@ -457,6 +457,178 @@ fn random_sort_has_no_date_headers() {
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
}
/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back.
#[test]
fn set_embedded_lens_is_undoable_and_journaled() {
let mut c = Catalog::default();
let a = c.alloc_photo_id();
c.apply(Op::AddPhoto {
photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
})
.unwrap();
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None };
let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
let inv = c.apply(op).unwrap();
assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens));
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
}
/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
/// with its problems; a good one has none; an old album operator is read as it was meant.
#[test]
fn smart_album_problems_flag_stale_rules() {
let mut c = Catalog::new();
c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
let smart = |id: u64, rules: serde_json::Value| {
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap();
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") }
};
c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap();
c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap();
assert!(c.smart_album_problems(AlbumId(2)).is_empty());
assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem");
assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules");
c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap();
let p = c.smart_album_problems(AlbumId(2));
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 1: no album 1".to_string()]);
}
/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded
/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they
/// change.
#[test]
fn a_smart_album_can_exclude_another() {
let mut c = Catalog::new();
let smart = |id: u64, name: &str, rules: serde_json::Value| {
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap();
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) }
};
c.apply(Op::AddAlbum {
album: smart(
1,
"Excluded Photos",
serde_json::json!({"match": "any", "rules": [
{"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}),
),
})
.unwrap();
let travel = |c: &mut Catalog, name: &str, label: Option<ColorLabel>, flag: Flag| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.meta.keywords = vec!["travel".into()];
p.label = label;
p.flag = flag;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let keep = travel(&mut c, "keep.jpg", None, Flag::Pick);
let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None);
let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject);
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
{"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]}))
.unwrap();
assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c));
let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::<Vec<_>>();
assert_eq!(matched(&c), vec![keep]);
// "is" works the other way round
let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap();
assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2);
let _ = (red, rejected);
}
/// An album can't include itself, directly or through other smart albums: the check refuses the
/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file)
/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no
/// photos, and every album in it is reported.
#[test]
fn smart_album_loops_are_refused_and_survived() {
let mut c = Catalog::new();
let refers = |to: u64| -> Filter {
serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap()
};
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
// A → B → C
c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap();
c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap();
c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap();
let rules = |to: u64| refers(to).rule_set.unwrap();
assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A");
assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself");
assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet");
assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop");
assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3)));
// a loop saved anyway: C → A
c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap();
let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00");
let p = c.photo(id).unwrap().clone();
for a in [1, 2, 3] {
let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow
assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported");
}
}
/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids;
/// one that is gone shows as its number.
#[test]
fn summaries_name_albums() {
let mut c = Catalog::new();
c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap();
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
{"field": "keywords", "op": "contains", "value": "travel"},
{"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]}))
.unwrap();
assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)");
let f = Filter { rule_set: Some(rules), ..Default::default() };
assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”"));
}
/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is
/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once
/// per question. 25 albums deep stays instant.
#[test]
fn chains_of_smart_albums_stay_linear() {
let mut c = Catalog::new();
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") };
c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap();
for k in 2..=26u64 {
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [
{"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}}))
.unwrap();
c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap();
}
for i in 0..20 {
photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00");
}
let start = std::time::Instant::now();
assert_eq!(c.album_count(AlbumId(26)), 20);
assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
}
/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is
/// reported, and so is A filtered to B while B's rules test A.
#[test]
fn an_album_filter_counts_in_loops() {
let mut c = Catalog::new();
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap();
let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop);
assert!(loops(&c, 1), "filtered to itself");
let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap();
c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap();
c.apply(Op::AddAlbum {
album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()),
})
.unwrap();
assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3)));
assert!(loops(&c, 2) && loops(&c, 3));
let _ = tests_3;
let shown: Vec<String> = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect();
assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}");
}
#[test]
fn undated_photos_group_under_unknown_date_and_sort_together() {
let mut c = Catalog::new();
+199
View File
@@ -0,0 +1,199 @@
//! What the library keeps about its folders: a colour label, like a photo's (see
//! [`crate::folders::FolderRecord`]).
//!
//! Scenarios, in the words of someone organising their library by folder:
//!
//! * Given a folder of the library, when I give it a colour label, its row in the folder tree
//! shows that label; the folders around it show none.
//! * However the folder is spelled, it is one folder with one label.
//! * When I take the label off, the library keeps nothing about the folder.
//! * Every change is one undo step: undo puts back the label the folder had before.
//! * A path that names no folder is refused and nothing changes.
//! * When the folder is renamed or moved, its label and those of the folders inside it go along;
//! a folder beside it whose name merely starts the same way keeps its own. Undo brings them
//! back, also a label that was left at the destination.
//! * Labels survive closing and reopening the library.
use crate::*;
fn add(c: &mut Catalog, path: &str) {
let id = c.alloc_photo_id();
let name = path.rsplit('/').next().unwrap_or(path);
let p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
fn library() -> Catalog {
let mut c = Catalog::new();
for p in ["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip2/c.jpg", "/pics/home/d.jpg"] {
add(&mut c, p);
}
c
}
fn label(c: &mut Catalog, path: &str, l: Option<ColorLabel>) -> Op {
let op = c.folder_label_op(path, l);
c.apply(op).unwrap()
}
/// The label of each row of the tree, by path, depth first (rows without one left out).
fn tree_labels(c: &Catalog) -> Vec<(String, ColorLabel)> {
fn walk(n: &FolderNode, out: &mut Vec<(String, ColorLabel)>) {
if let Some(l) = n.label {
out.push((n.path.clone(), l));
}
n.children.iter().for_each(|c| walk(c, out));
}
let mut out = Vec::new();
c.folder_tree().iter().for_each(|n| walk(n, &mut out));
out
}
#[test]
fn a_new_library_knows_nothing_about_its_folders() {
let c = library();
assert_eq!(c.folder_record("/pics/trip"), None);
assert_eq!(c.folder_color_label("/pics/trip"), None);
assert!(tree_labels(&c).is_empty());
}
#[test]
fn a_labelled_folder_shows_its_label_in_the_tree_and_only_there() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Red));
assert_eq!(tree_labels(&c), vec![("/pics/trip".to_string(), ColorLabel::Red)]);
}
#[test]
fn however_a_folder_is_spelled_it_has_one_label() {
let mut c = library();
label(&mut c, "/pics//trip/", Some(ColorLabel::Green));
assert_eq!(c.folder_color_label("/pics/./trip"), Some(ColorLabel::Green));
label(&mut c, "/pics/home/../trip", Some(ColorLabel::Blue));
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Blue));
assert_eq!(tree_labels(&c).len(), 1, "one folder, one record");
}
#[test]
fn taking_the_label_off_leaves_no_record() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Purple));
label(&mut c, "/pics/trip", None);
assert_eq!(c.folder_record("/pics/trip"), None);
assert!(!c.to_snapshot().contains("folder_records"), "nothing empty is saved");
}
#[test]
fn an_empty_record_is_no_record() {
let mut c = library();
c.apply(Op::SetFolderRecord { folder: "/pics/trip".into(), record: Some(FolderRecord::default()) }).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None);
}
#[test]
fn undo_puts_back_the_label_the_folder_had() {
let mut c = library();
let undo_first = label(&mut c, "/pics/trip", Some(ColorLabel::Yellow));
let undo_second = label(&mut c, "/pics/trip", Some(ColorLabel::Red));
c.apply(undo_second).unwrap();
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
c.apply(undo_first).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None);
}
#[test]
fn a_path_that_names_no_folder_is_refused() {
let mut c = library();
let before = c.to_snapshot();
for bad in ["", "relative/trip", ".", "a/.."] {
let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) };
assert!(c.apply(op).is_err(), "{bad:?} names no folder");
}
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn a_moved_folder_takes_its_labels_and_its_subfolders_labels_along() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
label(&mut c, "/pics/trip2", Some(ColorLabel::Blue));
let ops = c.folder_records_follow("/pics/trip", "/archive/2026/trip");
c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_record("/pics/trip"), None);
assert_eq!(c.folder_color_label("/archive/2026/trip"), Some(ColorLabel::Red));
assert_eq!(c.folder_color_label("/archive/2026/trip/day1"), Some(ColorLabel::Green));
assert_eq!(c.folder_color_label("/pics/trip2"), Some(ColorLabel::Blue), "a neighbour that only starts the same stays");
}
#[test]
fn undoing_a_move_brings_back_every_label_also_one_left_at_the_destination() {
let mut c = library();
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
// a folder that once was at the destination left its labels behind
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
label(&mut c, "/pics/holiday/day9", Some(ColorLabel::Yellow));
let before = c.to_snapshot();
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
let undo = c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Red), "the moved folder's label wins");
assert_eq!(c.folder_record("/pics/holiday/day9"), None, "what was known about the old folder goes");
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn a_folder_without_labels_moves_without_ops() {
let c = library();
assert!(c.folder_records_follow("/pics/trip", "/elsewhere/trip").is_empty());
}
#[test]
fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() {
let mut c = library();
// a folder that was at the destination once (its photos are gone) left its label behind
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
let undo = c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.folder_record("/pics/holiday"), None, "the folder now there is another one");
c.apply(undo).unwrap();
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple));
}
#[test]
fn labels_survive_the_journal_without_a_snapshot() {
let m = MemStore::new();
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
{
let p = "/pics/trip/a.jpg";
let id = c.alloc_photo_id();
let op = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: p.into() }, "a.jpg", "JPEG", 6, 4, "2026-01-01")) };
c.apply(op.clone()).unwrap();
j.append(std::slice::from_ref(&op)).unwrap();
}
let op = c.folder_label_op("/pics/trip", Some(ColorLabel::Yellow));
c.apply(op.clone()).unwrap();
j.append(std::slice::from_ref(&op)).unwrap();
drop(j);
let (_, reopened, r) = Journal::open(Box::new(m)).unwrap();
assert!(r.replayed >= 2, "{r:?}");
assert_eq!(reopened.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
}
#[test]
fn labels_survive_closing_and_reopening_the_library() {
let mut c = library();
label(&mut c, "/pics/home", Some(ColorLabel::Green));
let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
assert_eq!(reopened.folder_color_label("/pics/home"), Some(ColorLabel::Green));
}
#[test]
fn a_library_saved_before_folder_records_opens_without_any() {
let c = library();
let old = c.to_snapshot();
assert!(!old.contains("folder_records"));
let reopened = Catalog::from_snapshot(&old).unwrap();
assert_eq!(reopened.folder_record("/pics/trip"), None);
}
+20 -3
View File
@@ -47,7 +47,7 @@ fn v1_library_loads_and_is_upgraded() {
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3.
/// A library saved before albums could be ordered by hand opens as it was and is rewritten in the current format.
#[test]
fn v2_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
@@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() {
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(2)));
assert_eq!(snapshot_version(&m), 3);
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A library saved before keywords could be listed on their own opens as it was and is rewritten
/// in the current format.
#[test]
fn v3_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
m.set(LOG, log.into_bytes());
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(3)));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
#[test]
@@ -193,8 +207,11 @@ fn op_variants_are_versioned() {
| Op::Batch { .. } => 1,
Op::SetBrowsed { .. } => 2,
Op::SetAlbumOrder { .. } => 3,
Op::SetEmbeddedLens { .. } => 4,
Op::SetKeyword { .. } => 5,
Op::SetFolderRecord { .. } => 6,
}
}
let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None });
let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
+50
View File
@@ -78,6 +78,15 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) },
9 if b % 7 == 5 => Op::SetKeyword {
path: format!("travel|place {}", b % 4),
info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo {
person: a.is_multiple_of(2),
synonyms: vec![format!("trip {a}")],
..Default::default()
}),
},
9 if b % 11 == 4 => c.folder_label_op(&format!("/p/f{}", a % 4), ColorLabel::ALL.get(usize::from(b % 6)).copied()),
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
9 if b.is_multiple_of(2) => {
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
@@ -326,3 +335,44 @@ fn failed_streamed_write_keeps_the_old_file() {
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
/// A damaged rendering process in a saved photo (snapshot or log) reads as V1 and costs nothing
/// else: the photo and its edits load (`docs/process-versions.md`).
#[test]
fn a_damaged_process_value_loads_as_v1_with_the_edits() {
use lightcraft_develop::ProcessVersion;
let mut c = Catalog::new();
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
p.develop = Arc::new(DevelopSettings {
process: ProcessVersion(7),
light: lightcraft_develop::Light { exposure: 0.5, ..Default::default() },
..Default::default()
});
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let snapshot = c.to_snapshot();
assert!(snapshot.contains(r#""process":7"#));
let snapshot = format!(
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":0,\"catalog\":{}}}\n",
crate::journal::VERSION,
snapshot.replace(r#""process":7"#, r#""process":-1"#)
);
let m = MemStore::new();
m.set(SNAPSHOT, snapshot.into_bytes());
let (_, loaded, _) = open(&m);
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5));
// and in a (CRC-valid) log record
let edit = DevelopSettings {
process: ProcessVersion(7),
light: lightcraft_develop::Light { exposure: 0.8, ..Default::default() },
..Default::default()
};
let op = Op::SetDevelop { id, settings: Arc::new(edit), label: "Edit".into(), edited: None };
let body = serde_json::to_string(&op).unwrap().replace(r#""process":7"#, r#""process":"seven""#);
assert!(body.contains("seven"));
m.set(LOG, format!("{{\"seq\":1,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
let (_, loaded, _) = open(&m);
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
+5 -1
View File
@@ -30,6 +30,8 @@ jxl-oxide = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ravif = { workspace = true, optional = true }
rav1e = { workspace = true, optional = true }
avif-serialize = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
@@ -40,7 +42,9 @@ jxl = ["dep:jxl-oxide"]
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
heif = ["dep:lightcraft-heif"]
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
avif = ["dep:ravif"]
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+14 -5
View File
@@ -23,6 +23,12 @@ 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`
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?;
let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's
gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?;
```
Thumbnail fallback decoding limits the source to 64 million pixels by default.
@@ -46,8 +52,9 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| GIF / BMP | yes (first GIF frame) | — | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
## Colour
@@ -100,9 +107,11 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap
## Gaps / limitations
- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
(dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
`Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
- **AVIF decode**: no permissively licensed, pure-Rust AV1 decoder exists today (dav1d is C;
rav1d-safe is AGPL). Files are sniffed and rejected with `Error::Unsupported`.
- **HEIC** needs the `heif` feature (off by default: HEVC patents are the distributor's call);
without it, `Error::Unsupported` says the build has no HEIC support. 4:2:2 and 4:4:4 HEIF
(Canon/Sony HIF) is refused: heic-rs 0.1.1 loses sync on those bitstreams.
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
baseline) for maximum compatibility.
+442
View File
@@ -0,0 +1,442 @@
//! Monitor (display) ICC profiles: showing photos correctly on wide-gamut and calibrated displays.
//!
//! The loupe renders straight into the display's own primaries: the develop pipeline targets the
//! display's linear RGB ([`DisplayProfile::to_rec2020`]) and encodes it with the sRGB curve, as it
//! does for every 8-bit output. That "proxy" encoding then goes to the display's real device
//! values with [`DisplayProfile::correct`]: its tone curves for a matrix/TRC profile (a per-channel
//! table), or its LUTs for a LUT-based profile (a CMS transform). Images made for sRGB (thumbnails,
//! cached previews, embedded camera JPEGs) go through [`DisplayProfile::from_srgb`] instead.
//!
//! All conversions are relative colorimetric: the display's white is the image's white.
use std::sync::Arc;
use crate::icc;
use crate::space::Trc;
use lightcraft_color::{D50, D65, Mat3, REC2020, bradford};
use lightcraft_raster::Rgba8;
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions, Xyzd};
/// Largest profile file accepted (monitor profiles are a few KB; LUT profiles up to a few MB).
pub const MAX_PROFILE_BYTES: usize = 32 << 20;
/// How a display profile describes the display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayKind {
/// Primaries + tone curves (exact).
MatrixTrc,
/// LUT-based (A2B/B2A tables): applied through the CMS; the primaries the pipeline targets are
/// the profile's matrix tags when it has them, else measured through its tables.
Lut,
}
impl DisplayKind {
pub fn label(self) -> &'static str {
match self {
DisplayKind::MatrixTrc => "matrix",
DisplayKind::Lut => "lut",
}
}
}
/// The proxy → device step of a display.
enum Correction {
/// The proxy encoding is the display's (sRGB-curve matrix profile): nothing to do.
None,
/// Per-channel tables (matrix/TRC profile: same primaries, other curves).
Curves(Box<[[u8; 256]; 3]>),
/// A full CMS transform (LUT-based profile).
Cms(Arc<Transform8BitExecutor>),
}
/// A parsed display profile with its transforms (built once, applied to every preview).
pub struct DisplayProfile {
/// The profile's own description (or "Display profile").
pub description: String,
/// Identifies the profile (hash of its bytes): part of render keys.
pub id: u64,
pub kind: DisplayKind,
/// Linear display RGB → linear Rec.2020 (D65; display white → (1, 1, 1)).
pub to_rec2020: Mat3,
correction: Correction,
from_srgb: Arc<Transform8BitExecutor>,
to_srgb: Arc<Transform8BitExecutor>,
}
impl std::fmt::Debug for DisplayProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DisplayProfile")
.field("description", &self.description)
.field("id", &self.id)
.field("kind", &self.kind)
.field("to_rec2020", &self.to_rec2020)
.finish_non_exhaustive()
}
}
fn opts() -> TransformOptions {
TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() }
}
fn xyzd(v: [f64; 3]) -> Xyzd {
Xyzd { x: v[0], y: v[1], z: v[2] }
}
/// Linear Rec.2020 → XYZ relative to D50 (the ICC PCS), Bradford-adapted.
fn rec2020_to_xyz_d50() -> Mat3 {
bradford(D65, D50).mul(&REC2020.to_xyz())
}
/// Scale `m`'s columns so that (1, 1, 1) maps to (1, 1, 1): the display's white is the image's.
fn white_balanced(m: Mat3) -> Option<Mat3> {
let s = m.inverse()?.apply([1.0; 3]);
s.iter().all(|v| v.is_finite() && *v > 0.0).then(|| m.mul(&Mat3::diag(s[0], s[1], s[2])))
}
fn hash(bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish() | 1
}
/// Runs a moxcms call that may panic on a hostile profile.
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
}
impl DisplayProfile {
/// Parse a display profile (an RGB ICC profile, matrix/TRC or LUT-based) and build its
/// transforms. Errors say what is wrong with the file; nothing here panics.
pub fn from_icc(bytes: &[u8]) -> Result<DisplayProfile, String> {
if bytes.len() > MAX_PROFILE_BYTES {
return Err(format!("the profile is too large ({} MB)", bytes.len() >> 20));
}
let p = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
if p.color_space != DataColorSpace::Rgb {
return Err("not an RGB profile (a display profile describes an RGB device)".into());
}
if !matches!(p.pcs, DataColorSpace::Xyz | DataColorSpace::Lab) {
return Err("unsupported profile connection space".into());
}
let info = icc::parse(bytes).ok_or("not a valid ICC profile")?;
let description = info
.description
.as_deref()
.map(|d| d.trim_start_matches('\u{feff}').trim())
.filter(|d| !d.is_empty())
.unwrap_or("Display profile")
.to_string();
let has_lut = p.lut_a_to_b_colorimetric.is_some()
|| p.lut_a_to_b_perceptual.is_some()
|| p.lut_b_to_a_colorimetric.is_some()
|| p.lut_b_to_a_perceptual.is_some();
let srgb = ColorProfile::new_srgb();
// the primaries the pipeline renders into
let (kind, to_xyz_d50, curves) = match &info.kind {
icc::IccKind::MatrixTrc { to_xyz_d50, trc } if !has_lut => (DisplayKind::MatrixTrc, *to_xyz_d50, Some(trc.clone())),
icc::IccKind::MatrixTrc { to_xyz_d50, .. } => (DisplayKind::Lut, *to_xyz_d50, None),
_ => (DisplayKind::Lut, measured_to_xyz_d50(&p)?, None),
};
let to_rec2020 =
rec2020_to_xyz_d50().inverse().map(|m| m.mul(&to_xyz_d50)).and_then(white_balanced).ok_or("the profile's primaries are degenerate")?;
if to_rec2020.0.iter().flatten().any(|v| !v.is_finite()) {
return Err("the profile's primaries are degenerate".into());
}
// the proxy: the display's primaries (as the pipeline targets them), sRGB curve
let proxy_xyz = rec2020_to_xyz_d50().mul(&to_rec2020).0;
let mut proxy = ColorProfile::new_srgb();
proxy.cicp = None;
proxy.red_colorant = xyzd([proxy_xyz[0][0], proxy_xyz[1][0], proxy_xyz[2][0]]);
proxy.green_colorant = xyzd([proxy_xyz[0][1], proxy_xyz[1][1], proxy_xyz[2][1]]);
proxy.blue_colorant = xyzd([proxy_xyz[0][2], proxy_xyz[1][2], proxy_xyz[2][2]]);
let transform = |src: &ColorProfile, dst: &ColorProfile| guarded(|| src.create_transform_8bit(Layout::Rgba, dst, Layout::Rgba, opts()).ok());
let correction = match curves {
Some(trc) if trc.iter().all(|t| t.approx_eq(&Trc::Srgb, 2e-4)) => Correction::None,
Some(trc) => Correction::Curves(Box::new(curve_tables(&trc))),
None => Correction::Cms(transform(&proxy, &p).ok_or("the profile's tables can't be applied")?),
};
let from_srgb = transform(&srgb, &p).ok_or("can't convert sRGB to this profile")?;
let to_srgb = transform(&proxy, &srgb).ok_or("can't convert this profile to sRGB")?;
Ok(DisplayProfile { description, id: hash(bytes), kind, to_rec2020, correction, from_srgb, to_srgb })
}
/// Does [`correct`](Self::correct) change anything (the display's curves aren't sRGB's)?
pub fn needs_correction(&self) -> bool {
!matches!(self.correction, Correction::None)
}
/// A render in the display's primaries with the sRGB curve (the pipeline's display target) →
/// the display's device values, in place.
pub fn correct(&self, img: &mut Rgba8) -> Result<(), String> {
match &self.correction {
Correction::None => Ok(()),
Correction::Curves(t) => {
let map = |p: &mut [u8; 4]| *p = [t[0][p[0] as usize], t[1][p[1] as usize], t[2][p[2] as usize], p[3]];
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
img.data.par_chunks_mut(1 << 14).for_each(|c| c.iter_mut().for_each(map));
}
#[cfg(not(feature = "parallel"))]
img.data.iter_mut().for_each(map);
Ok(())
}
Correction::Cms(x) => apply(x, img),
}
}
/// An sRGB image → the display's device values, in place.
pub fn from_srgb(&self, img: &mut Rgba8) -> Result<(), String> {
apply(&self.from_srgb, img)
}
/// A render in the display's primaries with the sRGB curve → sRGB (colours outside sRGB are
/// clipped): for histograms and cached previews, which stay sRGB.
pub fn to_srgb(&self, img: &Rgba8) -> Result<Rgba8, String> {
let mut out = img.clone();
apply(&self.to_srgb, &mut out)?;
Ok(out)
}
}
/// Device RGB → PCS (XYZ D50) of the pure primaries, measured through the profile's tables
/// (LUT-only profiles have no colorant tags).
fn measured_to_xyz_d50(p: &ColorProfile) -> Result<Mat3, String> {
let lin = icc::linear_rec2020_profile();
let x = guarded(|| p.create_transform_f32(Layout::Rgb, &lin, Layout::Rgb, opts()).ok()).ok_or("the profile's tables can't be applied")?;
let src = [1.0f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
let mut dst = [0f32; 9];
guarded(|| x.transform(&src, &mut dst).ok()).ok_or("the profile's tables can't be applied")?;
let c = |i: usize| [dst[i] as f64, dst[i + 3] as f64, dst[i + 6] as f64];
// columns: each primary in linear Rec.2020
let m = Mat3([c(0), c(1), c(2)]);
if m.0.iter().flatten().any(|v| !v.is_finite()) || m.determinant().abs() < 1e-6 {
return Err("the profile's primaries are degenerate".into());
}
Ok(rec2020_to_xyz_d50().mul(&m))
}
/// sRGB-encoded 8-bit value → the display curve's code value, per channel.
fn curve_tables(trc: &[Trc; 3]) -> [[u8; 256]; 3] {
let mut t = [[0u8; 256]; 3];
for (k, tab) in t.iter_mut().enumerate() {
for (i, v) in tab.iter_mut().enumerate() {
let lin = lightcraft_color::transfer::srgb_to_linear(i as f32 / 255.0);
*v = (from_linear(&trc[k], lin) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
}
}
t
}
/// Inverse of [`Trc::to_linear`] on 0..1 (bisection: works for tables and every parametric form).
fn from_linear(t: &Trc, y: f32) -> f32 {
let y = y.clamp(0.0, 1.0);
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if t.to_linear(mid) < y {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
fn apply(x: &Arc<Transform8BitExecutor>, img: &mut Rgba8) -> Result<(), String> {
const CHUNK: usize = 1 << 14; // pixels
let src = img.data.clone();
let run = |s: &[[u8; 4]], d: &mut [[u8; 4]]| guarded(|| x.transform(s.as_flattened(), d.as_flattened_mut()).ok()).is_some();
#[cfg(feature = "parallel")]
let ok = {
use rayon::prelude::*;
src.par_chunks(CHUNK).zip(img.data.par_chunks_mut(CHUNK)).all(|(s, d)| run(s, d))
};
#[cfg(not(feature = "parallel"))]
let ok = src.chunks(CHUNK).zip(img.data.chunks_mut(CHUNK)).all(|(s, d)| run(s, d));
if ok { Ok(()) } else { Err("display profile transform failed".into()) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::space::NamedSpace;
use lightcraft_color::{DISPLAY_P3, SRGB};
fn img(px: &[[u8; 4]]) -> Rgba8 {
Rgba8 { width: px.len(), height: 1, data: px.to_vec() }
}
fn close(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
(0..4).all(|i| (a[i] as i32 - b[i] as i32).abs() <= tol)
}
#[test]
fn srgb_display_changes_nothing() {
let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::Srgb)).unwrap();
assert_eq!(d.kind, DisplayKind::MatrixTrc);
assert!(!d.needs_correction());
for (i, row) in d.to_rec2020.0.iter().enumerate() {
let want = SRGB.to_space(&REC2020).0[i];
for k in 0..3 {
assert!((row[k] - want[k]).abs() < 2e-3, "{:?}", d.to_rec2020);
}
}
let px = [[0, 0, 0, 255], [255, 255, 255, 255], [200, 30, 90, 128], [17, 140, 251, 7]];
let mut a = img(&px);
d.correct(&mut a).unwrap();
assert_eq!(a.data, px);
let mut b = img(&px);
d.from_srgb(&mut b).unwrap();
for (x, y) in b.data.iter().zip(&px) {
assert!(close(*x, *y, 1), "{x:?} {y:?}");
}
}
/// On a Display P3 monitor, sRGB red is shown with less red drive than full; P3 green (out of
/// sRGB) is reachable in the display's own primaries.
#[test]
fn p3_display() {
let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
assert!(!d.needs_correction(), "P3 uses the sRGB curve");
let want = DISPLAY_P3.to_space(&REC2020).0;
for i in 0..3 {
for k in 0..3 {
assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 2e-3, "{:?}", d.to_rec2020);
}
}
let mut red = img(&[[255, 0, 0, 255], [128, 128, 128, 255]]);
d.from_srgb(&mut red).unwrap();
assert!(red.data[0][0] < 245 && red.data[0][1] > 40, "{:?}", red.data[0]);
assert!(close(red.data[1], [128, 128, 128, 255], 1), "grey stays grey: {:?}", red.data[1]);
// the display's own pure green is outside sRGB: it clips there
let srgb = d.to_srgb(&img(&[[0, 255, 0, 255]])).unwrap();
assert!(srgb.data[0][0] < 10 && srgb.data[0][1] == 255, "{:?}", srgb.data[0]);
}
/// A gamma 2.2 display (Adobe RGB-like): the proxy's sRGB curve goes to the display's curve.
#[test]
fn gamma_display_uses_its_curve() {
let bytes = icc::write_matrix_trc(&lightcraft_color::ADOBE_RGB, &Trc::Gamma(2.2));
let d = DisplayProfile::from_icc(&bytes).unwrap();
assert!(d.needs_correction());
let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [10, 128, 240, 9]]);
d.correct(&mut a).unwrap();
assert_eq!(a.data[0], [0, 0, 0, 255]);
assert_eq!(a.data[1], [255, 255, 255, 255]);
for (k, v) in [10u8, 128, 240].into_iter().enumerate() {
let lin = lightcraft_color::transfer::srgb_to_linear(v as f32 / 255.0);
let want = (lin.powf(1.0 / 2.2) * 255.0).round() as i32;
assert!((a.data[2][k] as i32 - want).abs() <= 1, "{k}: {} vs {want}", a.data[2][k]);
}
assert_eq!(a.data[2][3], 9, "alpha kept");
}
#[test]
fn rejects_bad_profiles() {
assert!(DisplayProfile::from_icc(&[]).is_err());
assert!(DisplayProfile::from_icc(&[0u8; 300]).is_err());
let mut gray = ColorProfile::new_gray_with_gamma(2.2);
gray.cicp = None;
assert!(DisplayProfile::from_icc(&gray.encode().unwrap()).is_err());
// truncated / corrupted real profiles never panic
let good = icc::write_named(NamedSpace::DisplayP3);
for n in [10, 128, 140, 200, good.len() - 1] {
let _ = DisplayProfile::from_icc(&good[..n]);
}
for i in (0..good.len()).step_by(7) {
let mut b = good.clone();
b[i] ^= 0xa5;
let _ = DisplayProfile::from_icc(&b);
}
}
/// A LUT-based display profile with no colorant or TRC tags (its A2B/B2A tables describe a
/// Display P3 panel with a gamma 2.2 response): the primaries are measured through the tables
/// and every preview goes through the CMS.
fn lut_only_p3_gamma22() -> Vec<u8> {
use moxcms::{LutMultidimensionalType, LutWarehouse, Matrix3d, ProfileClass, ToneReprCurve, Vector3d};
let m = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).0;
let inv = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).inverse().unwrap().0;
let curves = |v: Vec<f32>| vec![ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v)];
let ident = || curves(vec![1.0]);
// mAB: A → CLUT → M → matrix → B (no CLUT: M curves linearize, the matrix goes to XYZ)
let a2b = LutWarehouse::Multidimensional(LutMultidimensionalType {
num_input_channels: 3,
num_output_channels: 3,
grid_points: [0; 16],
clut: None,
a_curves: vec![],
m_curves: curves(vec![2.2]),
matrix: Matrix3d { v: m.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
b_curves: ident(),
bias: Vector3d::default(),
});
let b2a = LutWarehouse::Multidimensional(LutMultidimensionalType {
num_input_channels: 3,
num_output_channels: 3,
grid_points: [0; 16],
clut: None,
a_curves: vec![],
m_curves: curves(vec![1.0 / 2.2]),
matrix: Matrix3d { v: inv.map(|r| r.map(|v| v * 65535.0 / 32768.0)) },
b_curves: ident(),
bias: Vector3d::default(),
});
let mut p = ColorProfile::new_srgb();
p.cicp = None;
p.profile_class = ProfileClass::DisplayDevice;
p.red_colorant = Xyzd::default();
p.green_colorant = Xyzd::default();
p.blue_colorant = Xyzd::default();
p.red_trc = None;
p.green_trc = None;
p.blue_trc = None;
p.lut_a_to_b_colorimetric = Some(a2b.clone());
p.lut_a_to_b_perceptual = Some(a2b);
p.lut_b_to_a_colorimetric = Some(b2a.clone());
p.lut_b_to_a_perceptual = Some(b2a);
p.encode().unwrap()
}
#[test]
fn lut_profile_goes_through_the_cms() {
let bytes = lut_only_p3_gamma22();
let parsed = ColorProfile::new_from_slice(&bytes).unwrap();
assert!(!parsed.is_matrix_shaper() && parsed.lut_b_to_a_colorimetric.is_some(), "a LUT-only profile");
let d = DisplayProfile::from_icc(&bytes).unwrap();
assert_eq!(d.kind, DisplayKind::Lut);
assert!(d.needs_correction());
// the measured primaries are P3's
let want = DISPLAY_P3.to_space(&REC2020).0;
for i in 0..3 {
for k in 0..3 {
assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 0.02, "{:?} vs {want:?}", d.to_rec2020);
}
}
// the correction is the gamma 2.2 curve; grey stays grey
let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 200]]);
d.correct(&mut a).unwrap();
assert!(close(a.data[0], [0, 0, 0, 255], 1) && close(a.data[1], [255, 255, 255, 255], 1), "{:?}", a.data);
let lin = lightcraft_color::transfer::srgb_to_linear(128.0 / 255.0);
let g = (lin.powf(1.0 / 2.2) * 255.0).round() as u8;
assert!(close(a.data[2], [g, g, g, 200], 2), "{:?} vs {g}", a.data[2]);
// sRGB red → less than full red drive on a P3 panel
let mut red = img(&[[255, 0, 0, 255]]);
d.from_srgb(&mut red).unwrap();
assert!(red.data[0][0] < 245 && red.data[0][1] > 30, "{:?}", red.data[0]);
}
#[test]
fn ids_identify_profiles() {
let a = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
let b = DisplayProfile::from_icc(&icc::write_named(NamedSpace::AdobeRgb)).unwrap();
let a2 = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
assert_ne!(a.id, b.id);
assert_eq!(a.id, a2.id);
}
}
+176 -29
View File
@@ -79,6 +79,46 @@ pub enum ChromaSubsampling {
const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF,
/// XMP and the ICC profile in chunks, in that order.
pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result<Vec<(u8, Vec<u8>)>> {
let mut segs: Vec<(u8, Vec<u8>)> = Vec::new();
let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
let [hi, lo] = p.to_be_bytes();
[1, hi, lo, hi, lo]
});
segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
if let Some(exif) = meta.exif {
if exif.len() > 65_527 {
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
}
segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
}
if let Some(xmp) = meta.xmp {
segs.push((0xE1, xmp_segment(xmp)?));
}
if let Some(icc) = meta.icc {
// ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
if chunks.len() > 255 {
return Err(Error::Encode("ICC profile too large for JPEG".into()));
}
for (i, c) in chunks.iter().enumerate() {
segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
}
}
Ok(segs)
}
/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment.
pub(crate) fn xmp_segment(xmp: &str) -> Result<Vec<u8>> {
let seg = [XMP_NS, xmp.as_bytes()].concat();
if seg.len() > 65_533 {
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
}
Ok(seg)
}
/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100.
pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
img.validate()?;
@@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
};
if subsampling != ChromaSubsampling::S422 {
// Parallel encoder (restart-interval bands on all cores).
let mut segs: Vec<(u8, Vec<u8>)> = Vec::new();
let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
let [hi, lo] = p.to_be_bytes();
[1, hi, lo, hi, lo]
});
segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
if let Some(exif) = meta.exif {
if exif.len() > 65_527 {
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
}
segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
}
if let Some(xmp) = meta.xmp {
let seg = [XMP_NS, xmp.as_bytes()].concat();
if seg.len() > 65_533 {
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
}
segs.push((0xE1, seg));
}
if let Some(icc) = meta.icc {
// ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
if chunks.len() > 255 {
return Err(Error::Encode("ICC profile too large for JPEG".into()));
}
for (i, c) in chunks.iter().enumerate() {
segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
}
}
let segs = jpeg_app_segments(meta)?;
let ch = if img.channels == 2 { 1 } else { img.channels as usize };
// `data` is already gray for 2-channel input (see above)
return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs));
@@ -351,6 +363,141 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
}
}
/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m².
pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1.
pub fn pq_encode(nits: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
}
/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m².
pub fn pq_decode(e: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let p = e.clamp(0.0, 1.0).powf(1.0 / M2);
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
}
/// 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.
/// `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>> {
let (w, h) = (width as usize, height as usize);
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
return Err(Error::Encode("HDR AVIF: sample buffer too short".into()));
}
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
{
use rav1e::prelude::*;
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL
let mut max_nits = 0f32;
let mut sum_nits = 0f64;
let planes: Vec<[u16; 3]> = rgb
.as_chunks::<3>()
.0
.iter()
.take(n)
.map(|c| {
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
sum_nits += y_nits as f64;
let [r, g, b] = nits.map(pq_encode);
let y = K[0] * r + K[1] * g + K[2] * b;
let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
[y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16)
})
.collect();
let q = quality.clamp(1, 100) as f32 / 100.0;
// ravif's quality → quantizer curve, so qualities match the SDR AVIF path
let x = if q >= 0.82 {
(1.0 - q) * 2.6
} else if q > 0.25 {
1.0 - 0.125 - q * 0.5
} else {
1.0 - q
};
let quantizer = (x * 255.0).round() as usize;
let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10));
ec.width = w;
ec.height = h;
ec.time_base = Rational::new(1, 1);
ec.bit_depth = 10;
ec.chroma_sampling = ChromaSampling::Cs444;
ec.pixel_range = PixelRange::Full;
ec.color_description = Some(ColorDescription {
color_primaries: ColorPrimaries::BT2020,
transfer_characteristics: TransferCharacteristics::SMPTE2084,
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.still_picture = true;
ec.quantizer = quantizer;
ec.min_quantizer = quantizer.min(255) as u8;
ec.speed_settings.multiref = false;
ec.speed_settings.rdo_lookahead_frames = 1;
ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None;
let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}"));
let mut ctx: Context<u16> = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?;
let mut frame = ctx.new_frame();
{
let mut it = planes.iter();
let mut fp = frame.planes.iter_mut();
let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else {
return Err(Error::Encode("AV1: frame has no planes".into()));
};
let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default()));
for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) {
for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) {
let Some(p) = it.next() else { break };
(*y, *u, *v) = (p[0], p[1], p[2]);
}
}
}
ctx.send_frame(frame).map_err(|x| e(&x))?;
ctx.flush();
let mut av1 = Vec::new();
loop {
match ctx.receive_packet() {
Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data),
Ok(_) => {}
Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break,
Err(x) => return Err(e(&x)),
}
}
use avif_serialize::constants as c;
let mut a = avif_serialize::Aviffy::new();
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
.set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
.set_color_primaries(c::ColorPrimaries::Bt2020)
.set_full_color_range(true)
.set_content_light_level(cll.0, cll.1);
if let Some(exif) = meta.exif {
a.set_exif(exif.to_vec());
}
Ok(a.to_vec(&av1, None, width, height, 10))
}
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
{
let _ = (quality, speed, meta);
Err(Error::Encode("AVIF encoding is not available in this build".into()))
}
}
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
+834
View File
@@ -0,0 +1,834 @@
//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to
//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays.
//!
//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear
//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit
//! codes), so quantisation in the base is handed back by the gain map.
//! - [`apply`] rebuilds the HDR rendition from a base and its gain map.
//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO
//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO
//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same
//! map (what survives an iMessage send).
//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom),
//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP.
//!
//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) − log2(sdr + offset_sdr)` as
//! `min + (max − min) · (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how
//! much headroom the display has between `base_headroom` and `alternate_headroom`.
use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment};
use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par};
/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated).
pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0";
/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output.
const DENOMINATOR: u32 = 1_000_000;
/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value
/// when the map is single-channel.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GainMapMeta {
/// Three gain channels (R, G, B) instead of one luminance gain.
pub multichannel: bool,
/// The gain applies in the base image's colour space.
pub use_base_colour_space: bool,
/// log2 headroom at which the base is shown as is (0: an SDR base).
pub base_headroom: f32,
/// log2 headroom at which the gain map applies in full.
pub alternate_headroom: f32,
pub min: [f32; 3],
pub max: [f32; 3],
pub gamma: [f32; 3],
pub base_offset: [f32; 3],
pub alternate_offset: [f32; 3],
}
impl Default for GainMapMeta {
fn default() -> Self {
GainMapMeta {
multichannel: false,
use_base_colour_space: true,
base_headroom: 0.0,
alternate_headroom: 1.0,
min: [0.0; 3],
max: [1.0; 3],
gamma: [1.0; 3],
base_offset: [1.0 / 64.0; 3],
alternate_offset: [1.0 / 64.0; 3],
}
}
}
impl GainMapMeta {
fn channels(&self) -> usize {
if self.multichannel { 3 } else { 1 }
}
/// Usable by [`apply`]: finite, positive gammas, max ≥ min, headrooms in order.
pub fn is_valid(&self) -> bool {
let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite());
fin(&self.min)
&& fin(&self.max)
&& fin(&self.gamma)
&& fin(&self.base_offset)
&& fin(&self.alternate_offset)
&& self.gamma.iter().all(|g| *g > 0.0)
&& self.min.iter().zip(&self.max).all(|(a, b)| b >= a)
&& self.base_headroom.is_finite()
&& self.alternate_headroom.is_finite()
&& self.alternate_headroom > self.base_headroom
}
/// The ISO 21496-1 APP2 payload of the gain map image, URN included.
pub fn to_iso(&self) -> Vec<u8> {
let mut p = ISO_URN.to_vec();
p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this
p.extend_from_slice(&0u16.to_be_bytes()); // writer_version
let mut flags = 0u8;
if self.multichannel {
flags |= FLAG_MULTICHANNEL;
}
if self.use_base_colour_space {
flags |= FLAG_BASE_COLOUR_SPACE;
}
p.push(flags);
let unsigned = |p: &mut Vec<u8>, v: f32| {
let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32;
p.extend_from_slice(&n.to_be_bytes());
p.extend_from_slice(&DENOMINATOR.to_be_bytes());
};
let signed = |p: &mut Vec<u8>, v: f32| {
let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32;
p.extend_from_slice(&n.to_be_bytes());
p.extend_from_slice(&DENOMINATOR.to_be_bytes());
};
unsigned(&mut p, self.base_headroom.max(0.0));
unsigned(&mut p, self.alternate_headroom.max(0.0));
for c in 0..self.channels() {
signed(&mut p, self.min[c]);
signed(&mut p, self.max[c]);
unsigned(&mut p, self.gamma[c]);
signed(&mut p, self.base_offset[c]);
signed(&mut p, self.alternate_offset[c]);
}
p
}
/// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only
/// marker a primary image carries, unknown versions, a base that is the HDR rendition, or
/// values no decoder could use.
pub fn from_iso(p: &[u8]) -> Option<GainMapMeta> {
let mut r = Reader { b: p, at: 0 };
let min_version = r.u16()?;
let _writer_version = r.u16()?;
if min_version != 0 {
return None;
}
let flags = r.u8()?;
if flags & FLAG_BACKWARD != 0 {
return None;
}
let multichannel = flags & FLAG_MULTICHANNEL != 0;
let common = flags & FLAG_COMMON_DENOMINATOR != 0;
let common_d = if common { r.u32()? } else { 0 };
let unsigned = |r: &mut Reader| -> Option<f32> {
let n = r.u32()?;
let d = if common { common_d } else { r.u32()? };
(d != 0).then(|| (n as f64 / d as f64) as f32)
};
let signed = |r: &mut Reader| -> Option<f32> {
let n = r.u32()? as i32;
let d = if common { common_d } else { r.u32()? };
(d != 0).then(|| (n as f64 / d as f64) as f32)
};
let mut m = GainMapMeta {
multichannel,
use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0,
base_headroom: unsigned(&mut r)?,
alternate_headroom: unsigned(&mut r)?,
..GainMapMeta::default()
};
for c in 0..m.channels() {
m.min[c] = signed(&mut r)?;
m.max[c] = signed(&mut r)?;
m.gamma[c] = unsigned(&mut r)?;
m.base_offset[c] = signed(&mut r)?;
m.alternate_offset[c] = signed(&mut r)?;
}
if !multichannel {
m.fill_from_first();
}
m.is_valid().then_some(m)
}
fn fill_from_first(&mut self) {
for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] {
a[1] = a[0];
a[2] = a[0];
}
}
/// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files
/// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists.
pub fn from_hdrgm_xmp(xmp: &str) -> Option<GainMapMeta> {
if !xmp.contains("hdrgm:") {
return None;
}
let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true")));
if base_rendition_hdr {
return None;
}
let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> {
let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) };
let f: Vec<f32> = v.iter().map(|s| s.trim().parse::<f32>().ok()).collect::<Option<_>>()?;
match f.as_slice() {
[a] => Some(([*a; 3], false)),
[a, b, c] => Some(([*a, *b, *c], true)),
_ => None,
}
};
let (max, m1) = get("GainMapMax", f32::NAN)?;
if max.iter().any(|v| v.is_nan()) {
return None; // the one required parameter
}
let (min, m2) = get("GainMapMin", 0.0)?;
let (gamma, m3) = get("Gamma", 1.0)?;
let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?;
let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?;
let (cap_min, _) = get("HDRCapacityMin", 0.0)?;
let max_gain = max.iter().copied().fold(f32::MIN, f32::max);
let (cap_max, _) = get("HDRCapacityMax", max_gain)?;
let m = GainMapMeta {
multichannel: m1 || m2 || m3 || m4 || m5,
use_base_colour_space: true,
base_headroom: cap_min[0].max(0.0),
alternate_headroom: cap_max[0],
min,
max,
gamma,
base_offset,
alternate_offset,
};
m.is_valid().then_some(m)
}
/// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which
/// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps).
pub fn to_gain_map_xmp(&self) -> String {
let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"];
let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset];
let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]);
let mut s = String::from(XMP_OPEN);
s.push_str(r#"<rdf:Description rdf:about="" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0""#);
s.push_str(&format!(r#" hdrgm:HDRCapacityMin="{}""#, num(self.base_headroom)));
s.push_str(&format!(r#" hdrgm:HDRCapacityMax="{}""#, num(self.alternate_headroom)));
s.push_str(r#" hdrgm:BaseRenditionIsHDR="False""#);
for (n, v) in names.iter().zip(values) {
if uniform(v) {
s.push_str(&format!(r#" hdrgm:{n}="{}""#, num(v[0])));
}
}
s.push('>');
// per-channel values are an rdf:Seq of three, not a comma list: readers parse each item
for (n, v) in names.iter().zip(values) {
if !uniform(v) {
s.push_str(&format!("<hdrgm:{n}><rdf:Seq>"));
for x in v {
s.push_str(&format!("<rdf:li>{}</rdf:li>", num(*x)));
}
s.push_str(&format!("</rdf:Seq></hdrgm:{n}>"));
}
}
s.push_str("</rdf:Description>");
s.push_str(&self.apple_description());
s.push_str(XMP_CLOSE);
s
}
/// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces).
fn apple_description(&self) -> String {
// 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels
let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 };
let mut s = String::from(
r#"<rdf:Description rdf:about="" xmlns:apdi="http://ns.apple.com/pixeldatainfo/1.0/" xmlns:HDRGainMap="http://ns.apple.com/HDRGainMap/1.0/" xmlns:HDRToneMap="http://ns.apple.com/HDRToneMap/1.0/">"#,
);
s.push_str("<apdi:AuxiliaryImageType>urn:com:apple:photo:2020:aux:hdrgainmap</apdi:AuxiliaryImageType>");
s.push_str(&format!("<apdi:NativeFormat>{format}</apdi:NativeFormat><apdi:StoredFormat>{format}</apdi:StoredFormat>"));
s.push_str("<HDRGainMap:HDRGainMapVersion>131072</HDRGainMap:HDRGainMapVersion>");
// linear, not log2: the headroom multiplier itself
s.push_str(&format!("<HDRGainMap:HDRGainMapHeadroom>{}</HDRGainMap:HDRGainMapHeadroom>", num(self.alternate_headroom.exp2())));
s.push_str(&format!("<HDRToneMap:AlternateHeadroom>{}</HDRToneMap:AlternateHeadroom>", num(self.alternate_headroom)));
s.push_str("<HDRToneMap:ChannelMetadata><rdf:Seq>");
for c in 0..self.channels() {
s.push_str(&format!(
r#"<rdf:li rdf:parseType="Resource"><HDRToneMap:GainMapMin>{}</HDRToneMap:GainMapMin><HDRToneMap:GainMapMax>{}</HDRToneMap:GainMapMax><HDRToneMap:Gamma>{}</HDRToneMap:Gamma><HDRToneMap:BaseOffset>{}</HDRToneMap:BaseOffset><HDRToneMap:AlternateOffset>{}</HDRToneMap:AlternateOffset></rdf:li>"#,
num(self.min[c]),
num(self.max[c]),
num(self.gamma[c]),
num(self.base_offset[c]),
num(self.alternate_offset[c])
));
}
s.push_str("</rdf:Seq></HDRToneMap:ChannelMetadata>");
s.push_str(&format!("<HDRToneMap:BaseHeadroom>{}</HDRToneMap:BaseHeadroom>", num(self.base_headroom)));
s.push_str("<HDRToneMap:BaseColorIsWorkingColor>True</HDRToneMap:BaseColorIsWorkingColor><HDRToneMap:Version>1</HDRToneMap:Version>");
s.push_str("</rdf:Description>");
s
}
}
const FLAG_MULTICHANNEL: u8 = 0x80;
const FLAG_BASE_COLOUR_SPACE: u8 = 0x40;
const FLAG_COMMON_DENOMINATOR: u8 = 0x08;
const FLAG_BACKWARD: u8 = 0x04;
const XMP_OPEN: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">";
const XMP_CLOSE: &str = "</rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";
/// A short decimal (up to 6 significant digits, no exponent for ordinary values).
fn num(v: f32) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{:.6}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() }
}
/// The values of `hdrgm:<name>`: an attribute (`name="v"`) or an element holding an `rdf:Seq`.
fn xmp_values(xmp: &str, name: &str) -> Option<Vec<String>> {
let attr = format!("hdrgm:{name}=");
if let Some(i) = xmp.find(&attr) {
let rest = xmp.get(i + attr.len()..)?;
let q = rest.chars().next()?;
if q == '"' || q == '\'' {
let body = rest.get(1..)?;
let end = body.find(q)?;
return Some(vec![body.get(..end)?.to_string()]);
}
}
let open = format!("<hdrgm:{name}>");
let close = format!("</hdrgm:{name}>");
let i = xmp.find(&open)?;
let body = xmp.get(i + open.len()..)?;
let body = body.get(..body.find(&close)?)?;
let mut out = Vec::new();
let mut rest = body;
while let Some(a) = rest.find("<rdf:li>") {
let after = rest.get(a + 8..)?;
let b = after.find("</rdf:li>")?;
out.push(after.get(..b)?.to_string());
rest = after.get(b..)?;
if out.len() > 3 {
return None;
}
}
if out.is_empty() {
// a plain element value
out.push(body.trim().to_string());
}
Some(out)
}
struct Reader<'a> {
b: &'a [u8],
at: usize,
}
impl Reader<'_> {
fn take<const N: usize>(&mut self) -> Option<[u8; N]> {
let s = self.b.get(self.at..self.at.checked_add(N)?)?;
self.at += N;
s.try_into().ok()
}
fn u8(&mut self) -> Option<u8> {
self.take::<1>().map(|a| a[0])
}
fn u16(&mut self) -> Option<u16> {
self.take().map(u16::from_be_bytes)
}
fn u32(&mut self) -> Option<u32> {
self.take().map(u32::from_be_bytes)
}
}
/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes.
#[derive(Clone, Debug, PartialEq)]
pub struct GainImage {
pub width: usize,
pub height: usize,
pub channels: usize,
pub data: Vec<u8>,
}
impl GainImage {
/// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5).
fn sample(&self, x: f32, y: f32, c: usize) -> f32 {
let (w, h) = (self.width, self.height);
let fx = (x - 0.5).clamp(0.0, (w - 1) as f32);
let fy = (y - 0.5).clamp(0.0, (h - 1) as f32);
let (x0, y0) = (fx as usize, fy as usize);
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32;
let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx;
let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx;
a + (b - a) * ty
}
}
/// How [`compute`] builds the map.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GainMapOptions {
/// Downscale factor of the map (1, 2 or 4).
pub scale: usize,
/// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single
/// channel, so this is off by default.
pub multichannel: bool,
pub gamma: f32,
/// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet).
pub offset: f32,
}
impl Default for GainMapOptions {
fn default() -> Self {
GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 }
}
}
/// Measure the gain map that turns `sdr` into `hdr` (both `width × height` linear RGB in the same
/// primaries; `luma` = that space's luminance weights).
pub fn compute(
sdr: &[[f32; 3]],
hdr: &[[f32; 3]],
width: usize,
height: usize,
luma: [f32; 3],
o: &GainMapOptions,
) -> Result<(GainImage, GainMapMeta)> {
let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
if n == 0 || sdr.len() < n || hdr.len() < n {
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
}
if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
}
let s = o.scale;
let (gw, gh) = (width.div_ceil(s), height.div_ceil(s));
let ch = if o.multichannel { 3 } else { 1 };
let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2];
let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2();
// block means of the log gains
let mut gains = vec![0f32; gw * gh * ch];
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for gy in 0..gh {
for gx in 0..gw {
let mut acc = [0f32; 3];
let mut count = 0f32;
for yy in gy * s..((gy + 1) * s).min(height) {
for xx in gx * s..((gx + 1) * s).min(width) {
let i = yy * width + xx;
let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue };
if o.multichannel {
for c in 0..3 {
acc[c] += ratio(a[c], b[c]);
}
} else {
acc[0] += ratio(y(a), y(b));
}
count += 1.0;
}
}
for c in 0..ch {
let g = if count > 0.0 { acc[c] / count } else { 0.0 };
let g = if g.is_finite() { g } else { 0.0 };
gains[(gy * gw + gx) * ch + c] = g;
lo[c] = lo[c].min(g);
hi[c] = hi[c].max(g);
}
}
}
let mut m = GainMapMeta {
multichannel: o.multichannel,
gamma: [o.gamma; 3],
base_offset: [o.offset; 3],
alternate_offset: [o.offset; 3],
..GainMapMeta::default()
};
for c in 0..ch {
// the range always includes 0 (no change) so an SDR-only area encodes exactly
m.min[c] = lo[c].min(0.0);
m.max[c] = hi[c].max(m.min[c] + 1e-4);
}
if !o.multichannel {
m.fill_from_first();
}
m.base_headroom = 0.0;
m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3);
let data = gains
.iter()
.enumerate()
.map(|(i, g)| {
let c = i % ch;
let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0);
(norm.powf(o.gamma) * 255.0).round() as u8
})
.collect();
Ok((GainImage { width: gw, height: gh, channels: ch, data }, m))
}
/// Rebuild the HDR rendition in place: `base` (`width × height` linear RGB) is replaced by the
/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom
/// `H` uses `(H − base_headroom) / (alternate_headroom − base_headroom)`, clamped).
pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> {
let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
if base.len() < n
|| gain.width == 0
|| gain.height == 0
|| !matches!(gain.channels, 1 | 3)
|| gain.data.len() < gain.width * gain.height * gain.channels
{
return Err(Error::Encode("gain map: base and gain map sizes don't match".into()));
}
if !m.is_valid() {
return Err(Error::Encode("gain map: unusable metadata".into()));
}
let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 };
let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32);
let inv_gamma = m.gamma.map(|g| 1.0 / g);
for (i, p) in base.iter_mut().enumerate().take(n) {
let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5);
let (gx, gy) = (x * sx, y * sy);
let mut g = [0f32; 3];
for (c, gc) in g.iter_mut().enumerate() {
let src = if gain.channels == 3 { c } else { 0 };
let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]);
*gc = m.min[c] + (m.max[c] - m.min[c]) * norm;
}
for c in 0..3 {
let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c];
p[c] = v.max(0.0);
}
}
Ok(())
}
/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for
/// Adobe/Google readers) and the Container directory naming the gain map and its length.
fn primary_description(gain_len: usize) -> String {
format!(
concat!(
r#"<rdf:Description rdf:about="" xmlns:Container="http://ns.google.com/photos/1.0/container/" xmlns:Item="http://ns.google.com/photos/1.0/container/item/" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0">"#,
r#"<Container:Directory><rdf:Seq>"#,
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="Primary" Item:Mime="image/jpeg"/></rdf:li>"#,
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="GainMap" Item:Mime="image/jpeg" Item:Length="{}"/></rdf:li>"#,
r#"</rdf:Seq></Container:Directory></rdf:Description>"#
),
gain_len
)
}
/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is
/// none, or when it has no `rdf:RDF` to add to).
fn merge_xmp(xmp: Option<&str>, description: &str) -> String {
if let Some(x) = xmp
&& let Some(i) = x.rfind("</rdf:RDF>")
&& let (Some(a), Some(b)) = (x.get(..i), x.get(i..))
{
return format!("{a}{description}{b}");
}
format!("{XMP_OPEN}{description}{XMP_CLOSE}")
}
/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags).
const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4;
/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists.
fn mpf_placeholder() -> Vec<u8> {
let mut p = b"MPF\0MM".to_vec();
p.extend_from_slice(&42u16.to_be_bytes());
p.extend_from_slice(&8u32.to_be_bytes()); // first IFD
p.extend_from_slice(&3u16.to_be_bytes());
let tag = |p: &mut Vec<u8>, tag: u16, ty: u16, count: u32, value: [u8; 4]| {
p.extend_from_slice(&tag.to_be_bytes());
p.extend_from_slice(&ty.to_be_bytes());
p.extend_from_slice(&count.to_be_bytes());
p.extend_from_slice(&value);
};
tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion
tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages
tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry
p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
// entry 0: baseline primary (0x030000) flagged as the representative image (bit 29)
for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] {
p.extend_from_slice(&attr.to_be_bytes());
p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries
}
p
}
/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and
/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added.
pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
let Samples::U8(data) = base.samples else {
return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into()));
};
let (w, h) = (base.width as usize, base.height as usize);
if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize {
return Err(Error::Encode("gain map JPEG needs an RGB base image".into()));
}
if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize {
return Err(Error::Encode("JPEG dimensions are limited to 65535".into()));
}
if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels {
return Err(Error::Encode("gain map image is malformed".into()));
}
if !m.is_valid() || m.multichannel != (gain.channels == 3) {
return Err(Error::Encode("gain map metadata doesn't match the map".into()));
}
// jpeg_par writes 4:4:4 or 4:2:0
let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub };
let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())];
let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs);
let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len()));
let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?;
let mut iso_marker = ISO_URN.to_vec();
iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map"
segs.push((0xE2, iso_marker));
segs.push((0xE2, mpf_placeholder()));
let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs);
// patch the MPF index: sizes, and the gain map's offset from the MPF endian field
let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?;
let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?;
let entries = endian + MPF_ENTRIES_FROM_ENDIAN;
let (plen, glen) = (primary.len(), gain_jpeg.len());
let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)];
for (at, v) in fields {
let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?;
let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?;
slot.copy_from_slice(&v.to_be_bytes());
}
primary.extend_from_slice(&gain_jpeg);
Ok(primary)
}
/// The gain map of a gain map JPEG.
#[derive(Clone, Debug, PartialEq)]
pub struct JpegGainMap {
pub gain: GainImage,
pub meta: GainMapMeta,
}
/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The
/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`].
pub fn read_jpeg(bytes: &[u8]) -> Option<JpegGainMap> {
let m = jpeg::parse_markers(bytes)?;
let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"));
if !marked {
return None;
}
for e in jpeg::mpf_images(&m) {
let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue };
let Some(gm) = jpeg::parse_markers(data) else { continue };
let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp));
let Some(meta) = meta else { continue };
let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue };
if ch == 3 && !meta.multichannel {
// a single-channel map stored as RGB: use its first channel
let g: Vec<u8> = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect();
return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta });
}
let mut meta = meta;
if ch == 1 && meta.multichannel {
meta.multichannel = false;
meta.fill_from_first();
}
return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta });
}
None
}
/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode).
pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool {
crate::sniff(bytes) == Some(Format::Jpeg)
&& jpeg::parse_markers(bytes).is_some_and(|m| {
(m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")))
&& jpeg::mpf_images(&m)
.iter()
.any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map()))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn meta3() -> GainMapMeta {
GainMapMeta {
multichannel: true,
use_base_colour_space: true,
base_headroom: 0.0,
alternate_headroom: 2.5,
min: [-0.25, -0.5, 0.0],
max: [2.5, 2.25, 2.0],
gamma: [1.0, 1.25, 0.75],
base_offset: [0.015625; 3],
alternate_offset: [0.015625, 0.03125, 0.015625],
}
}
#[test]
fn iso_metadata_round_trips() {
for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] {
let p = m.to_iso();
assert!(p.starts_with(ISO_URN));
let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap();
for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}");
}
}
assert_eq!(back.multichannel, m.multichannel);
assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6);
}
}
#[test]
fn iso_common_denominator_and_rejections() {
// common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0
let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR];
for v in [4u32, 0, 8, 0, 8, 4, 0, 0] {
p.extend_from_slice(&v.to_be_bytes());
}
let m = GainMapMeta::from_iso(&p).unwrap();
assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0));
// versions only (a primary image's marker), unknown version, truncated, backward
assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none());
assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none());
assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none());
let mut back = p.clone();
back[4] |= FLAG_BACKWARD;
assert!(GainMapMeta::from_iso(&back).is_none());
// a zero denominator is rejected, not divided by
let mut z = p.clone();
z[5..9].copy_from_slice(&0u32.to_be_bytes());
assert!(GainMapMeta::from_iso(&z).is_none());
}
#[test]
fn hdrgm_xmp_round_trips_including_per_channel_sequences() {
for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] {
let x = m.to_gain_map_xmp();
assert!(x.contains("apdi:AuxiliaryImageType"));
if m.multichannel {
assert!(x.contains("<hdrgm:GainMapMax><rdf:Seq><rdf:li>2.5</rdf:li>"), "{x}");
}
let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap();
assert_eq!(back.multichannel, m.multichannel);
for c in 0..3 {
assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5);
}
assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5);
}
assert!(GainMapMeta::from_hdrgm_xmp("<x hdrgm:Version=\"1.0\"/>").is_none(), "no GainMapMax");
assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none());
}
fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
// a ramp: SDR rolls off at 1, HDR keeps going to 6
let hdr: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let t = (i % w) as f32 / (w - 1) as f32;
let v = 0.01 + 6.0 * t * t;
[v, v * 0.8, v * 0.6]
})
.collect();
// a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single
// luminance gain can rebuild it exactly
let sdr = hdr
.iter()
.map(|p| {
let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
let k = 1.0 / (1.0 + y * 0.5);
p.map(|v| v * k)
})
.collect();
(sdr, hdr)
}
#[test]
fn compute_then_apply_recovers_the_hdr_rendition() {
let (w, h) = (64, 8);
let (sdr, hdr) = renditions(w, h);
for multichannel in [false, true] {
let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() };
let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap();
assert!(m.is_valid() && m.alternate_headroom > 1.0);
let mut out = sdr.clone();
apply(&mut out, w, h, &g, &m, 1.0).unwrap();
for (a, b) in out.iter().zip(&hdr) {
for c in 0..3 {
// 8-bit codes over the gain range: within ~2 % in linear light
assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}");
}
}
// weight 0 is the base
let mut base = sdr.clone();
apply(&mut base, w, h, &g, &m, 0.0).unwrap();
for (a, b) in base.iter().zip(&sdr) {
assert!((a[0] - b[0]).abs() < 1e-5);
}
}
}
#[test]
fn jpeg_round_trip_and_container_structure() {
let (w, h) = (96, 64);
let (sdr, hdr) = renditions(w, h);
let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
let base: Vec<u8> =
sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect();
let user_xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"/></rdf:RDF></x:xmpmeta>"#;
let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() };
let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap();
assert!(is_gain_map_jpeg(&file));
// the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP
let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();
assert_eq!((d.width, d.height), (w as u32, h as u32));
let x = d.xmp.unwrap();
assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\""));
// the MPF index points at the gain map, typed as one
let pm = jpeg::parse_markers(&file).unwrap();
let e = jpeg::mpf_images(&pm);
assert_eq!(e.len(), 1);
assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP);
assert_eq!(e[0].offset + e[0].len, file.len());
// and the gain map reads back
let r = read_jpeg(&file).unwrap();
assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1));
assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5);
// the gain map is never a thumbnail
if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) {
assert_ne!(t.source, crate::ThumbnailSource::MpfPreview);
}
}
#[test]
fn garbage_never_panics() {
assert!(read_jpeg(b"").is_none());
assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none());
assert!(!is_gain_map_jpeg(&[0xFF, 0xD8]));
let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40];
b.extend_from_slice(ISO_URN);
b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]);
assert!(read_jpeg(&b).is_none());
let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] };
assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err());
assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err());
}
}
+54 -28
View File
@@ -1,16 +1,18 @@
//! HEIF / HEIC (the iPhone and Mac photo format), read-only.
//!
//! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC
//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC
//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call
//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still
//! detected and opening one is an [`Error::Unsupported`] error, never a panic.
//! still-picture decoder, plus a libheif-matching colour conversion), enabled by this crate's
//! `heif` feature — off by default because HEVC is patent-encumbered and whether a build carries
//! an HEVC decoder is the distributor's call (the same policy as the sibling PhotoCraft). Without
//! the feature, HEIF files are still detected and opening one is an [`Error::Unsupported`] error
//! saying so, never a panic.
//!
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
//! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only
//! mirrors what the container says, and applying it as well would turn the photo twice.
//! The samples come back in the container's own primaries with its ICC profile (if any); like
//! every decoder here, the colour interpretation happens in [`crate::convert::finish`].
//! The samples come back as 16-bit RGB in the container's own primaries, with its ICC profile
//! (iPhones: Display P3) or, without one, its `nclx` / VUI colour description; like every decoder
//! here, the colour interpretation happens in [`crate::convert::finish`].
#[cfg(feature = "heif")]
use crate::convert::{Buf, Meta, Model, Raw, finish};
@@ -22,26 +24,32 @@ const F: Format = Format::Heif;
#[cfg(not(feature = "heif"))]
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
/// only; the wrapper names the exact construct in its own errors).
#[cfg(feature = "heif")]
const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)";
#[cfg(not(feature = "heif"))]
pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result<Decoded> {
Err(Error::Unsupported(F, NOT_IN_BUILD))
}
#[cfg(not(feature = "heif"))]
pub(crate) fn header(_bytes: &[u8]) -> Result<(u32, u32, u16)> {
Err(Error::Unsupported(F, NOT_IN_BUILD))
}
#[cfg(feature = "heif")]
fn err(e: lightcraft_heif::Error) -> Error {
match e {
// This crate's Unsupported takes a fixed reason; the dynamic wording lives in the
// lightcraft-heif error itself.
lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED),
lightcraft_heif::Error::Unsupported(why) => Error::Unsupported(F, why),
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
}
}
/// The displayed size (container transforms applied, so orientation 1), from the container
/// alone: no picture is decoded.
#[cfg(feature = "heif")]
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let info = lightcraft_heif::probe(bytes).map_err(err)?;
Ok((info.width, info.height, 1))
}
#[cfg(feature = "heif")]
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
// The container alone: the declared size is checked before any pixel is decoded.
@@ -50,16 +58,22 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
return Err(Error::TooLarge(info.width as u64, info.height as u64));
}
let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true };
let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?;
// The decoder re-reads the container; trust its output's shape, not the probe's.
let buf = if decoded.sixteen_bit {
let mut v = Vec::with_capacity(decoded.data.len() / 2);
for pair in decoded.data.as_chunks::<2>().0 {
v.push(u16::from_ne_bytes(*pair));
// A small decode (a thumbnail or a cataloguing probe) uses the file's own thumbnail image when
// it is at least as large as asked: iPhones store one of 320 × 240, and decoding it is a
// hundredth of the work of the 12 MP photo.
let thumb = match opts.max_size {
Some((mw, mh)) if mw > 0 && mh > 0 && mw.max(mh) < info.width.max(info.height) => {
lightcraft_heif::decode_thumbnail(bytes, mw.max(mh), &options).ok().flatten().filter(|t| {
// Only a thumbnail of the same picture: same orientation and aspect within 1 %.
let aspect = |w: u32, h: u32| w as f64 / h.max(1) as f64;
(aspect(t.width, t.height) / aspect(info.width, info.height) - 1.0).abs() < 0.01
})
}
Buf::U16(v)
} else {
Buf::U8(decoded.data)
_ => None,
};
let mut decoded = match thumb {
Some(t) => t,
None => lightcraft_heif::decode(bytes, &options).map_err(err)?,
};
let raw = Raw {
width: decoded.width as usize,
@@ -67,11 +81,20 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
model: Model::Rgb,
alpha: decoded.has_alpha,
premultiplied: false,
buf,
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
buf: Buf::U16(std::mem::take(&mut decoded.data)),
bit_depth: decoded.bit_depth,
};
let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() };
finish(F, raw, meta, (decoded.width, decoded.height), opts)
let c = decoded.colour;
let meta = Meta {
icc: decoded.icc,
exif: decoded.exif,
xmp: decoded.xmp,
orientation: Some(1),
// Used when there is no (usable) ICC profile.
hint: crate::space::SourceSpace::from_cicp(c.primaries, c.transfer),
..Default::default()
};
finish(F, raw, meta, (info.width, info.height), opts)
}
#[cfg(test)]
@@ -96,6 +119,8 @@ mod tests {
fn without_the_feature_heif_is_a_clear_unsupported_error() {
let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}");
let h = crate::read_header(HEIC_HEADER);
assert!(matches!(&h, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{h:?}");
}
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
@@ -113,6 +138,7 @@ mod tests {
fn a_heic_rs_panic_is_a_malformed_error() {
let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}");
assert!(crate::read_header(&HEIC_RS_BOX_PANIC).is_err());
}
#[cfg(feature = "heif")]
@@ -120,6 +146,6 @@ mod tests {
fn an_image_sequence_is_unsupported() {
// An ftyp with no meta box: a video track, not a still.
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default());
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}");
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("sequence")), "{r:?}");
}
}
+1 -1
View File
@@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec<u8> {
write_matrix_trc(&n.rgb_space(), &n.trc())
}
fn linear_rec2020_profile() -> ColorProfile {
pub(crate) fn linear_rec2020_profile() -> ColorProfile {
let mut p = ColorProfile::new_bt2020();
p.cicp = None;
let lin = ToneReprCurve::Parametric(vec![1.0]);
+61 -6
View File
@@ -25,6 +25,17 @@ pub(crate) struct Markers {
pub adobe_transform: Option<u8>,
/// Offset of the MPF TIFF header within the file and its bytes.
pub mpf: Option<(usize, Vec<u8>)>,
/// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image
/// that has a gain map, the full metadata on the gain map image itself.
pub iso_gainmap: Option<Vec<u8>>,
}
impl Markers {
/// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a
/// picture: never a preview, never the image to show.
pub fn is_gain_map(&self) -> bool {
self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax"))
}
}
pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
@@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
icc_chunks.push((seg[12], &seg[14..]));
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
m.mpf = Some((start + 4, seg[4..].to_vec()));
} else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
&& m.iso_gainmap.is_none()
{
m.iso_gainmap = Some(p.to_vec());
}
}
0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => {
@@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) {
candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail));
}
for (o, l) in mpf_images(&m) {
if let Some(s) = bytes.get(o..o.saturating_add(l)) {
for e in mpf_images(&m) {
if e.kind == MPF_GAIN_MAP {
continue;
}
if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) {
candidates.push((s, ThumbnailSource::MpfPreview));
}
}
@@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
let mut best: Option<(u32, &[u8], ThumbnailSource)> = None;
for (data, src) in candidates {
let Some(pm) = parse_markers(data) else { continue };
// a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview
if pm.is_gain_map() {
continue;
}
let long = pm.width.max(pm.height);
// Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped.
let aspect_ok = m.width > 0
@@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height })
}
/// (file offset, length) of MPF-listed images other than the primary.
fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits).
pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000;
/// An MPF-listed image other than the primary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MpfEntry {
/// File offset and length of the image.
pub offset: usize,
pub len: usize,
/// MP type code (attribute low 24 bits).
pub kind: u32,
}
/// The MPF-listed images other than the primary.
pub(crate) fn mpf_images(m: &Markers) -> Vec<MpfEntry> {
let Some((base, data)) = &m.mpf else { return vec![] };
let Some(t) = exif::Tiff::new(data) else { return vec![] };
let Some(ifd) = t.first_ifd() else { return vec![] };
@@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]];
if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
};
let (size, off) = (rd(4) as usize, rd(8) as usize);
let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
out.push((base + off, size));
out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF });
}
}
out
}
/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale
/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours.
pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec<u8>)> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if !matches!(m.components, 1 | 3) || m.precision != 8 {
return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into()));
}
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? };
let ch = raw.model.channels();
match raw.buf {
Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)),
_ => Err(Error::Malformed(F, "unexpected sample format".into())),
}
}
#[cfg(test)]
mod tests {
use super::*;
+12 -7
View File
@@ -17,8 +17,10 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod display;
pub mod encode;
pub mod exif;
pub mod gainmap;
mod heif;
pub mod icc;
mod jpeg;
@@ -34,7 +36,8 @@ mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless,
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
encode_png, encode_tiff, encode_webp_lossless,
};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
@@ -171,9 +174,9 @@ pub struct Header {
}
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
/// decode.
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read
/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to
/// a small decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
@@ -196,6 +199,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
Format::Tiff => tiff_codec::header(bytes)?,
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
Format::Heif => heif::header(bytes)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
@@ -217,6 +221,8 @@ pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: Na
#[doc(hidden)]
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
let opts = *opts;
// The reason a format can't be decoded lives in one place, `Format::not_decodable`.
let unsupported = || Err(Error::Unsupported(format, format.not_decodable().unwrap_or("not decodable in this build")));
match format {
Format::Jpeg => jpeg::decode(bytes, &opts),
Format::Png => png_codec::decode(bytes, &opts),
@@ -227,10 +233,9 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
#[cfg(feature = "jxl")]
Format::Jxl => jxl::decode(bytes, &opts),
#[cfg(not(feature = "jxl"))]
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
Format::Jxl => unsupported(),
Format::Heif => heif::decode(bytes, &opts),
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),
}
}
+4 -19
View File
@@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
}
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
if let Some(c) = &info.coding_independent_code_points {
let named = match c.color_primaries {
1 => Some(NamedSpace::Srgb),
9 => Some(NamedSpace::Rec2020),
12 => Some(NamedSpace::DisplayP3),
_ => None,
};
let trc = match c.transfer_function {
13 => Some(Trc::Srgb),
1 | 6 | 14 | 15 => Some(Trc::Rec709),
8 => Some(Trc::Linear),
4 => Some(Trc::Gamma(2.2)),
_ => None,
};
if let (Some(n), Some(t)) = (named, trc) {
let mut s = SourceSpace::named(n, SpaceOrigin::Container);
s.trc = Some([t.clone(), t.clone(), t]);
return Some(s);
}
if let Some(s) =
info.coding_independent_code_points.as_ref().and_then(|c| SourceSpace::from_cicp(c.color_primaries.into(), c.transfer_function.into()))
{
return Some(s);
}
if info.srgb.is_some() {
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));
+13 -4
View File
@@ -64,11 +64,20 @@ impl Format {
/// Whether [`crate::decode`] can decode this format in this build.
pub fn can_decode(self) -> bool {
self.not_decodable().is_none()
}
/// Why [`crate::decode`] can't decode this format in this build (`None`: it can). The
/// [`crate::Error::Unsupported`] a decode returns carries the same reason.
pub fn not_decodable(self) -> Option<&'static str> {
match self {
Format::Jxl => cfg!(feature = "jxl"),
Format::Heif => cfg!(feature = "heif"),
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
_ => true,
#[cfg(not(feature = "jxl"))]
Format::Jxl => Some("built without the `jxl` feature"),
#[cfg(not(feature = "heif"))]
Format::Heif => Some(crate::heif::NOT_IN_BUILD),
Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"),
Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"),
_ => None,
}
}
+21
View File
@@ -255,6 +255,27 @@ impl SourceSpace {
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
}
/// The space named by ITU-T H.273 code points (PNG `cICP`, HEIF `nclx`, HEVC VUI), when
/// both the primaries and the transfer are ones this crate models.
pub(crate) fn from_cicp(primaries: u16, transfer: u16) -> Option<SourceSpace> {
let named = match primaries {
1 => NamedSpace::Srgb,
9 => NamedSpace::Rec2020,
12 => NamedSpace::DisplayP3,
_ => return None,
};
let trc = match transfer {
13 => Trc::Srgb,
1 | 6 | 14 | 15 => Trc::Rec709,
8 => Trc::Linear,
4 => Trc::Gamma(2.2),
_ => return None,
};
let mut s = SourceSpace::named(named, SpaceOrigin::Container);
s.trc = Some([trc.clone(), trc.clone(), trc]);
Some(s)
}
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
if let Some(n) = self.named {
+13 -1
View File
@@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) {
let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
let _ = lightcraft_codecs::gainmap::read_jpeg(bytes);
let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes);
}
fn gain_map_jpeg(meta: &EncodeMeta) -> Vec<u8> {
use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg};
let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect();
let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect();
let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
let base: Vec<u8> = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect();
encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap()
}
#[test]
@@ -91,6 +102,7 @@ fn seeds() -> &'static Vec<Vec<u8>> {
encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(),
encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(),
encode_webp_lossless(&e, &meta).unwrap(),
gain_map_jpeg(&meta),
]
})
}
@@ -106,7 +118,7 @@ proptest! {
}
#[test]
fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
let mut b = seeds()[which].clone();
for (i, v) in flips {
let i = i.index(b.len());
+98
View File
@@ -0,0 +1,98 @@
//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from
//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a
//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input.
#![cfg(feature = "heif")]
use lightcraft_codecs::*;
use lightcraft_heif::testdata::{Prop, Spec, build};
fn grey(x: u32, y: u32) -> [u16; 3] {
[((x * 7 + y * 13) % 220 + 20) as u16, 128, 128]
}
/// EXIF whose Orientation (6) only mirrors the container's `irot`: it must not turn the photo again.
fn exif_orientation_6() -> Vec<u8> {
let mut t = b"MM\0\x2a\0\0\0\x08\0\x01".to_vec();
t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0]);
t.extend_from_slice(&[0, 0, 0, 0]);
t
}
/// The thumbnail is flat mid grey so a test can tell it from the photo.
fn iphone_like() -> Vec<u8> {
build(&Spec {
props: vec![Prop::Icc(icc::write_named(NamedSpace::DisplayP3)), Prop::Irot(3)],
exif: Some(exif_orientation_6()),
thumbnail: Some(&|_, _| [126, 128, 128]),
ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap"],
..Spec::new(96, 64, &grey)
})
}
#[test]
fn an_iphone_like_heic_decodes_upright_in_display_p3() {
let bytes = iphone_like();
assert_eq!(sniff(&bytes), Some(Format::Heif));
let d = decode(&bytes, DecodeOptions::default()).unwrap();
// irot 3 = 90° clockwise, applied by the decoder; the EXIF Orientation is not applied again.
assert_eq!((d.width, d.height, d.source_width, d.source_height, d.orientation), (64, 96, 64, 96, 1));
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
assert_eq!((d.bit_depth, d.has_alpha, d.float), (8, false, false));
assert!(d.exif.is_some() && d.icc.is_some());
// No nclx and no VUI: limited range, BT.601; neutral chroma stays neutral.
let srgb = d.to_srgb8();
let px = srgb.data[0];
assert!(px[0].abs_diff(px[1]) <= 1 && px[1].abs_diff(px[2]) <= 1, "{px:?}");
let h = read_header(&bytes).unwrap();
assert_eq!((h.format, h.width, h.height, h.orientation), (Format::Heif, 64, 96, 1));
}
#[test]
fn a_small_decode_uses_the_embedded_thumbnail() {
let bytes = iphone_like();
// The 48 × 32 thumbnail, turned like the photo, covers a 32-pixel request.
let d = decode(&bytes, DecodeOptions::fit(32, 32)).unwrap();
assert!(d.width.max(d.height) <= 32 && d.height > d.width, "{}x{}", d.width, d.height);
assert_eq!((d.source_width, d.source_height), (64, 96));
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
let flat = d.to_srgb8();
assert!(flat.data.iter().all(|p| *p == flat.data[0]), "the flat thumbnail, not the photo");
// A thumbnail too small for the request: the photo is decoded and resized.
let d = decode(&bytes, DecodeOptions::fit(60, 60)).unwrap();
assert_eq!((d.width.max(d.height), d.source_width), (60, 64));
let photo = d.to_srgb8();
assert!(photo.data.iter().any(|p| *p != photo.data[0]));
let t = decode_thumbnail(&bytes, 32).unwrap();
assert_eq!((t.source_width, t.source_height), (64, 96));
}
#[test]
fn nclx_colour_and_ten_bit() {
// Display P3 primaries, sRGB transfer, BT.709 matrix, full range: the nclx alone names the space.
let bytes = build(&Spec { bit_depth: 10, props: vec![Prop::Nclx(12, 13, 1, true)], ..Spec::new(64, 32, &|x, _| [(x * 16) as u16, 512, 512]) });
let d = decode(&bytes, DecodeOptions::default()).unwrap();
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
assert_eq!(d.space.origin, SpaceOrigin::Container);
assert_eq!(d.bit_depth, 10);
// Full-range 10-bit neutral steps of 16 code values: linear values rise monotonically.
let row: Vec<f32> = (0..64).map(|x| d.image.row(0)[x][1]).collect();
assert!(row.windows(2).all(|w| w[1] > w[0]), "{row:?}");
}
#[test]
fn hostile_heic_is_an_error_never_a_panic() {
let bytes = iphone_like();
let full = decode(&bytes, DecodeOptions::default()).unwrap();
for cut in (12..bytes.len()).step_by(11) {
let b = &bytes[..cut];
// Cut inside the photo: an error. Cut in a trailing item (gain map, thumbnail): the photo.
if let (Ok(_), Ok(d)) = (read_header(b), decode(b, DecodeOptions::default())) {
assert_eq!(d.image.data, full.image.data, "cut at {cut}");
}
let _ = decode_thumbnail(b, 16);
}
let r = decode(&bytes, DecodeOptions { max_pixels: 64 * 96 - 1, ..Default::default() });
assert!(matches!(r, Err(Error::TooLarge(64, 96))), "{r:?}");
}
+7
View File
@@ -140,6 +140,13 @@ controls! {
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
"hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain;
"hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered;
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
+27 -1
View File
@@ -56,11 +56,13 @@ impl DevelopSettings {
h
}
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values, and
/// the rendering process: that is not an edit).
pub fn is_unedited(&self) -> bool {
let mut a = self.clone();
let d = DevelopSettings::default();
a.wb = d.wb;
a.process = d.process;
a == d
}
@@ -130,6 +132,9 @@ impl DevelopSettings {
}
}
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests {
use super::*;
@@ -162,6 +167,27 @@ mod tests {
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
#[test]
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
let s = DevelopSettings::default();
assert!(s.to_json().get("hdr").is_none(), "HDR off must not change the settings JSON or hash64");
let mut on = s.clone();
on.hdr.enabled = true;
assert!(on.to_json().get("hdr").is_some());
assert_ne!(on.hash64(), s.hash64());
assert_eq!(DevelopSettings::from_json(&on.to_json()).unwrap(), on);
}
#[test]
fn hdr_peak_is_finite_for_a_non_finite_headroom() {
let mut h = Hdr { enabled: true, max_ev: f64::NAN, ..Hdr::default() };
assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
h.max_ev = f64::INFINITY;
assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
h.max_ev = -3.0;
assert_eq!(h.peak(), 1.0);
}
#[test]
fn missing_fields_take_defaults_and_unknown_ignored() {
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
+27 -2
View File
@@ -3,6 +3,10 @@
//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it
//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current
//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]).
//!
//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets
//! made from a photo never carry it, so the photos they change keep their own. A partial that names
//! `process` itself (Apply Settings JSON) sets it like any other field.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -98,7 +102,7 @@ impl SettingsGroup {
SettingsGroup::Profile => &["profile"],
SettingsGroup::Treatment => &["treatment"],
SettingsGroup::WhiteBalance => &["wb"],
SettingsGroup::Light => &["light"],
SettingsGroup::Light => &["light", "hdr"],
SettingsGroup::ToneCurve => &["curve"],
SettingsGroup::Color => &["color"],
SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"],
@@ -162,7 +166,9 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
Value::Object(out)
}
(Value::Number(b), Value::Number(p)) => {
// Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would
// not deserialize as an integer, and the whole preset would silently not apply.
(Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => {
let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0));
serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null)
}
@@ -267,6 +273,25 @@ mod tests {
assert_eq!(lots.light.exposure, 5.0);
}
/// A preset with the Grain group (its seed is an integer) used to do nothing at any amount
/// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped.
#[test]
fn amount_keeps_integer_fields_whole() {
let mut a = DevelopSettings::default();
a.light.exposure = 1.0;
a.grain.amount = 40.0;
a.grain.seed = 12345;
let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed");
let base = DevelopSettings::default();
for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] {
let out = p.apply(&base, amount);
assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure);
assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount);
assert_eq!(out.grain.seed, seed, "{amount}: seed");
}
}
#[test]
fn deep_merge_objects() {
let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});
+177 -1
View File
@@ -1,19 +1,118 @@
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
//! older/newer files load (unknown fields are ignored, missing fields take defaults). The one
//! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were
//! made with.
use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
/// The rendering process a photo's settings are interpreted by (what Lightroom calls the process
/// version). A change that would alter how existing settings render ships as a new process, so a
/// photo keeps its look until someone updates it (`develop.updateProcess`). See
/// `docs/process-versions.md`.
///
/// Stored as a plain number: one written by a newer LightCraft loads, is kept as it is, and
/// renders with the newest process this build knows ([`ProcessVersion::process`]). Reading is
/// lenient: a whole number from 0 to 2^32 - 1 (also written as `2.0`) is that process; anything
/// else (negative, fractional, too large, text, null, a list…) reads as V1, like a missing field,
/// so a damaged value never costs the rest of the settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ProcessVersion(pub u32);
impl<'de> Deserialize<'de> for ProcessVersion {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Whole(u64),
Number(f64),
Other(serde::de::IgnoredAny),
}
Ok(match Wire::deserialize(d)? {
Wire::Whole(n) => u32::try_from(n).map_or_else(|_| ProcessVersion::legacy(), ProcessVersion),
Wire::Number(x) if x.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&x) => ProcessVersion(x as u32),
Wire::Number(_) | Wire::Other(_) => ProcessVersion::legacy(),
})
}
}
impl ProcessVersion {
/// The first process: LightCraft's rendering from before process versions existed. Frozen.
pub const V1: ProcessVersion = Process::V1.version();
/// The process new photos and Reset get.
pub const LATEST: ProcessVersion = Process::LATEST.version();
/// The process of settings saved without one (catalogs, snapshots, XMP sidecars and browser
/// libraries written before process versions existed): V1, whatever [`Self::LATEST`] becomes.
pub fn legacy() -> ProcessVersion {
ProcessVersion::V1
}
/// Saved settings leave the field out for V1, so files and preview-cache keys of settings made
/// before process versions existed stay byte for byte what they were.
pub fn is_legacy(&self) -> bool {
*self == ProcessVersion::legacy()
}
/// The process this build renders with: the newest one it knows that isn't newer than this
/// number (a number from a newer LightCraft renders with [`Process::LATEST`]; one below V1,
/// which no LightCraft writes, with V1).
pub fn process(self) -> Process {
Process::ALL.into_iter().rev().find(|p| p.version() <= self).unwrap_or(Process::V1)
}
/// A process this build implements.
pub fn is_known(self) -> bool {
Process::ALL.iter().any(|p| p.version() == self)
}
/// Older than [`Self::LATEST`]: Update to Current Process applies.
pub fn is_outdated(self) -> bool {
self < ProcessVersion::LATEST
}
}
/// A rendering process this build implements. Matches on it are exhaustive, so adding a process
/// makes the compiler point at every stage whose behaviour depends on it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Process {
/// The rendering of LightCraft before process versions existed (2026-10).
V1,
}
impl Process {
/// Every process, oldest first.
pub const ALL: [Process; 1] = [Process::V1];
pub const LATEST: Process = Process::V1;
pub const fn version(self) -> ProcessVersion {
match self {
Process::V1 => ProcessVersion(1),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DevelopSettings {
/// Schema version of this structure (not the rendering process, see `process`).
pub version: u32,
/// The rendering process ([`ProcessVersion`]): the latest for new settings, V1 for settings
/// saved without the field (the field-level default: not the struct's `Default`) or with a
/// value that isn't a process number.
#[serde(default = "ProcessVersion::legacy", skip_serializing_if = "ProcessVersion::is_legacy")]
pub process: ProcessVersion,
pub profile: Profile,
pub treatment: Treatment,
pub wb: WhiteBalance,
pub light: Light,
/// HDR editing: highlights above SDR white, and the SDR rendition derived from them. Not written while it is
/// the default, so settings without HDR keep their JSON and `hash64` (and every cached preview stays valid).
#[serde(skip_serializing_if = "Hdr::is_default")]
pub hdr: Hdr,
pub curve: ToneCurve,
pub color: ColorAdj,
pub mixer: Mixer,
@@ -44,10 +143,12 @@ impl Default for DevelopSettings {
fn default() -> Self {
Self {
version: SCHEMA_VERSION,
process: ProcessVersion::LATEST,
profile: Profile::default(),
treatment: Treatment::Color,
wb: WhiteBalance::default(),
light: Light::default(),
hdr: Hdr::default(),
curve: ToneCurve::default(),
color: ColorAdj::default(),
mixer: Mixer::default(),
@@ -931,3 +1032,78 @@ impl Enhance {
self.denoise_on.unwrap_or(self.denoise > 0.0)
}
}
/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0)
/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's
/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the
/// `sdr_*` offsets applied.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Hdr {
pub enabled: bool,
/// Headroom limit: stops above SDR white the HDR render may reach.
pub max_ev: f64,
/// SDR rendition offsets (−100..100): brightness (±1 EV), then added to the matching sliders.
pub sdr_brightness: f64,
pub sdr_contrast: f64,
pub sdr_highlights: f64,
pub sdr_shadows: f64,
pub sdr_whites: f64,
pub sdr_clarity: f64,
}
impl Default for Hdr {
fn default() -> Self {
Self {
enabled: false,
max_ev: Hdr::DEFAULT_MAX_EV,
sdr_brightness: 0.0,
sdr_contrast: 0.0,
sdr_highlights: 0.0,
sdr_shadows: 0.0,
sdr_whites: 0.0,
sdr_clarity: 0.0,
}
}
}
impl Hdr {
pub const DEFAULT_MAX_EV: f64 = 3.0;
pub const MAX_EV_LIMIT: f64 = 5.0;
/// Whether these are the default HDR settings (HDR off, nothing changed).
pub fn is_default(&self) -> bool {
*self == Hdr::default()
}
/// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). A non-finite headroom
/// (a damaged file or argument) falls back to the default, so the peak is always finite and at least 1.
pub fn peak(&self) -> f32 {
if !self.enabled {
return 1.0;
}
let ev = if self.max_ev.is_finite() { self.max_ev } else { Hdr::DEFAULT_MAX_EV };
(ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2()
}
}
impl DevelopSettings {
/// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the
/// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity.
pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> {
if !self.hdr.enabled {
return std::borrow::Cow::Borrowed(self);
}
let h = self.hdr;
let mut s = self.clone();
s.hdr.enabled = false;
let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0);
s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0);
s.light.contrast = add(s.light.contrast, h.sdr_contrast);
s.light.highlights = add(s.light.highlights, h.sdr_highlights);
s.light.shadows = add(s.light.shadows, h.sdr_shadows);
s.light.whites = add(s.light.whites, h.sdr_whites);
s.effects.clarity = add(s.effects.clarity, h.sdr_clarity);
std::borrow::Cow::Owned(s)
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Process versions: settings saved before they existed stay on V1, new settings get the latest,
//! and numbers this build doesn't know load without harm.
use serde_json::{Value, json};
use crate::*;
/// Complete settings as LightCraft wrote them before process versions existed (main at 8f924a9:
/// `serde_json::to_string` of a raw photo with tone, colour, curve, grading, effects and a radial
/// mask), one top-level key per line. There is no `process` field.
const LEGACY: &str = r#"
{
"version": 1,
"profile": {"id": "lc.color", "amount": 100.0},
"treatment": "color",
"wb": {"mode": "asShot", "temp": 5200.0, "tint": 4.0},
"light": {"exposure": 0.4, "contrast": 30.0, "highlights": -60.0, "shadows": 40.0, "whites": 15.0, "blacks": -20.0},
"curve": {"highlights": -10.0, "lights": 0.0, "darks": 10.0, "shadows": 0.0, "split_shadows": 25.0, "split_mid": 50.0, "split_highlights": 75.0, "master": [{"x": 0.0, "y": 0.05}, {"x": 0.5, "y": 0.55}, {"x": 1.0, "y": 0.95}], "red": [], "green": [], "blue": [], "refine_saturation": 100.0},
"color": {"vibrance": 20.0, "saturation": -5.0},
"mixer": {"red": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "orange": {"hue": 0.0, "sat": 0.0, "lum": 15.0}, "yellow": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "green": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "aqua": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blue": {"hue": 0.0, "sat": -30.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "magenta": {"hue": 0.0, "sat": 0.0, "lum": 0.0}},
"point_colors": [],
"bw_mix": {"red": 0.0, "orange": 0.0, "yellow": 0.0, "green": 0.0, "aqua": 0.0, "blue": 0.0, "purple": 0.0, "magenta": 0.0},
"grading": {"shadows": {"hue": 220.0, "sat": 20.0, "lum": -5.0}, "midtones": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 15.0, "lum": 5.0}, "global": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blending": 50.0, "balance": 0.0},
"effects": {"texture": 20.0, "clarity": 25.0, "dehaze": 15.0},
"vignette": {"amount": -25.0, "midpoint": 50.0, "roundness": 0.0, "feather": 50.0, "highlights": 0.0, "style": "highlightPriority"},
"grain": {"amount": 20.0, "size": 25.0, "roughness": 50.0, "seed": 0},
"detail": {"sharpen_amount": 40.0, "sharpen_radius": 1.0, "sharpen_detail": 25.0, "sharpen_masking": 0.0, "nr_luminance": 20.0, "nr_detail": 50.0, "nr_contrast": 0.0, "nr_color": 25.0, "nr_color_detail": 50.0, "nr_color_smoothness": 50.0},
"optics": {"remove_ca": false, "lens_profile": false, "profile_distortion": 100.0, "profile_vignetting": 100.0, "defringe_purple_amount": 0.0, "defringe_purple_hue_lo": 30.0, "defringe_purple_hue_hi": 70.0, "defringe_green_amount": 0.0, "defringe_green_hue_lo": 40.0, "defringe_green_hue_hi": 60.0, "distortion": 0.0, "vignetting": 0.0, "vignetting_midpoint": 50.0, "ca_red": 0.0, "ca_blue": 0.0},
"geometry": {"upright": "off", "guides": [], "upright_transform": null, "vertical": 0.0, "horizontal": 0.0, "rotate": 0.0, "aspect": 0.0, "scale": 100.0, "offset_x": 0.0, "offset_y": 0.0, "constrain_crop": false},
"crop": {"geometry": {"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}, "aspect": null, "flip_h": false, "flip_v": false},
"orientation": "Normal",
"masks": [{"id": 1, "name": "Mask 1", "visible": true, "invert": false, "components": [{"op": "add", "invert": false, "shape": {"kind": "radial", "center": {"x": 0.4, "y": 0.5}, "rx": 0.3, "ry": 0.2, "angle": 15.0, "feather": 60.0, "invert": false}}], "adjust": {"temp": 10.0, "tint": 0.0, "exposure": 0.5, "contrast": 0.0, "highlights": 0.0, "shadows": 0.0, "whites": 0.0, "blacks": 0.0, "texture": 0.0, "clarity": 30.0, "dehaze": 0.0, "hue": 0.0, "saturation": 0.0, "sharpness": 0.0, "noise": 0.0, "moire": 0.0, "defringe": 0.0, "color_hue": 0.0, "color_sat": 0.0, "amount": 100.0}}],
"spots": [],
"red_eye": [],
"lens_blur": {"enabled": false, "amount": 0.0, "focal_distance": 0.0, "focal_range": 0.0},
"enhance": {"denoise": 0.0, "raw_details": false, "super_resolution": false},
"calibration": {"shadows_tint": 0.0, "red_hue": 0.0, "red_sat": 0.0, "green_hue": 0.0, "green_sat": 0.0, "blue_hue": 0.0, "blue_sat": 0.0},
"disabled_sections": []
}
"#;
fn legacy() -> Value {
serde_json::from_str(LEGACY).unwrap()
}
#[test]
fn settings_saved_before_process_versions_load_as_v1() {
let v = legacy();
assert!(v.get("process").is_none(), "the fixture predates the field");
let s = DevelopSettings::from_json(&v).unwrap();
// V1 as a number, not `LATEST`: once a newer process is the latest, this is what keeps every
// existing catalog, sidecar and preset on the rendering it was made with
assert_eq!(s.process, ProcessVersion(1));
assert_eq!(s.process, ProcessVersion::legacy());
assert_eq!((s.light.exposure, s.light.highlights, s.masks.len(), s.detail.sharpen_amount), (0.4, -60.0, 1, 40.0));
// the smallest legacy file too, and settings nested in other records (history, versions)
assert_eq!(DevelopSettings::from_json(&json!({})).unwrap().process, ProcessVersion(1));
let nested: Vec<DevelopSettings> = serde_json::from_value(json!([{}, {"light": {"exposure": 1.0}}])).unwrap();
assert!(nested.iter().all(|s| s.process == ProcessVersion(1)));
// saved again, they gain no field: files and preview-cache keys (`hash64`) stay as they were
assert!(s.to_json().get("process").is_none());
}
#[test]
fn new_settings_get_the_latest_process() {
assert_eq!(DevelopSettings::default().process, ProcessVersion::LATEST);
assert_eq!(DevelopSettings::for_raw(5200.0, 4.0).process, ProcessVersion::LATEST);
assert_eq!(ProcessVersion::LATEST, Process::LATEST.version());
assert_eq!(ProcessVersion::V1, ProcessVersion(1));
// the processes are listed oldest first, the latest last, each with its own number
let versions: Vec<u32> = Process::ALL.iter().map(|p| p.version().0).collect();
assert!(versions.windows(2).all(|w| w[0] < w[1]), "{versions:?}");
assert_eq!(Process::ALL.last(), Some(&Process::LATEST));
assert!(Process::ALL.iter().all(|p| p.version().process() == *p && p.version().is_known()));
}
#[test]
fn v1_is_left_out_when_saving_and_other_processes_are_kept() {
let mut s = DevelopSettings::from_json(&legacy()).unwrap();
assert!(s.to_json().get("process").is_none());
let before = s.hash64();
s.process = ProcessVersion(2);
let v = s.to_json();
assert_eq!(v["process"], json!(2));
assert_eq!(DevelopSettings::from_json(&v).unwrap().process, ProcessVersion(2));
assert_ne!(s.hash64(), before, "another process is another preview");
}
#[test]
fn unknown_process_numbers_load_and_render_with_the_newest_known() {
// written by a newer LightCraft: kept as it is, rendered with the latest process this build has
let s = DevelopSettings::from_json(&json!({"process": 7, "light": {"exposure": 0.5}})).unwrap();
assert_eq!(s.process, ProcessVersion(7));
assert_eq!(s.process.process(), Process::LATEST);
assert!(!s.process.is_known());
assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process");
assert_eq!(s.to_json()["process"], json!(7));
assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(7));
// below V1 (no LightCraft writes it): rendered as V1, and updatable
let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process;
assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true));
let max = DevelopSettings::from_json(&json!({"process": u32::MAX})).unwrap().process;
assert_eq!(max.process(), Process::LATEST);
// a whole number written as a float is that number
assert_eq!(DevelopSettings::from_json(&json!({"process": 2.0})).unwrap().process, ProcessVersion(2));
}
#[test]
fn a_damaged_process_value_reads_as_v1_and_keeps_the_edits() {
let too_big = u64::from(u32::MAX) + 1;
for bad in [
json!(-1),
json!(1.5),
json!(-0.5),
json!(1e300),
json!(too_big),
json!(u64::MAX),
json!("2"),
json!(null),
json!(true),
json!([2]),
json!({"v": 2}),
] {
let mut v = legacy();
v["process"] = bad.clone();
let s = DevelopSettings::from_json(&v).unwrap_or_else(|e| panic!("{bad}: {e}"));
assert_eq!(s.process, ProcessVersion::V1, "{bad}");
assert_eq!((s.light.exposure, s.masks.len(), s.curve.master.len()), (0.4, 1, 3), "{bad}: the rest of the settings load");
// nested in other records too (history, versions, the catalog)
let nested: Vec<DevelopSettings> = serde_json::from_value(json!([v])).unwrap();
assert_eq!(nested[0].process, ProcessVersion::V1);
}
}
#[test]
fn the_process_is_not_an_edit_nor_part_of_copied_settings() {
let mut s = DevelopSettings { process: ProcessVersion(LATEST_PLUS_ONE), ..Default::default() };
assert!(s.is_unedited(), "a photo on another process is not edited for that");
s.light.exposure = 1.0;
assert!(!s.is_unedited());
// resetting a section or a slider leaves the process alone
s.reset_section(Section::Light);
assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE));
// copy / paste / sync / presets: no group carries the process, so the target keeps its own
assert!(SettingsGroup::ALL.iter().all(|g| !g.keys().contains(&"process")));
s.light.exposure = 1.0;
let copied = extract_groups(&s, &SettingsGroup::ALL);
assert!(copied.get("process").is_none());
let target = DevelopSettings::from_json(&legacy()).unwrap();
let pasted = apply_partial(&target, &copied, 1.0);
assert_eq!((pasted.process, pasted.light.exposure), (ProcessVersion(1), 1.0));
// (at another amount, without Grain: its integer seed doesn't survive scaling, a separate issue)
let groups: Vec<SettingsGroup> = SettingsGroup::ALL.into_iter().filter(|g| *g != SettingsGroup::Grain).collect();
let preset = Preset::from_settings("p", "P", "User", &s, &groups);
let half = preset.apply(&target, 0.5);
assert_eq!((half.process, half.light.exposure), (ProcessVersion(1), 0.7));
// a partial that names a process explicitly (Apply Settings JSON) sets it
assert_eq!(target.merged(&json!({"process": LATEST_PLUS_ONE})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE));
}
/// A process other than the latest: a newer LightCraft's number (no older one than V1 exists).
const LATEST_PLUS_ONE: u32 = ProcessVersion::LATEST.0 + 1;
+2
View File
@@ -49,5 +49,7 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
lightcraft-heif = { workspace = true, features = ["testdata"] }
libc = { workspace = true }
lightcraft-tiff = { workspace = true }
+9 -7
View File
@@ -247,18 +247,20 @@ fn main() {
}
}
if run("batch") && args.len() > 1 {
// full-size JPEG export of all the files given (decode + render + encode)
use lightcraft_engine::export::export_photo;
// full-size JPEG export of all the files given (decode + render + encode), as the Export
// dialog runs it: several photos side by side (`export_parallelism`), files discarded
use lightcraft_engine::export::{Destination, export_batch};
let mut session = lightcraft_engine::Session::new().with_fs();
let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import");
let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect();
let items: Vec<_> = ids.iter().enumerate().map(|(i, id)| (*id, i + 1)).collect();
let o = ExportOptions::default();
row(&format!("batch export of {} files (decode+render+JPEG):", items.len()), &mut |g| {
let lanes = lightcraft_engine::export::export_parallelism(ids.len());
row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| {
lightcraft_engine::gpu::set_enabled(g);
for &(id, seq) in &items {
drop(export_photo(&mut session, id, &o, seq).expect("export"));
session.media.forget(id); // as in a batch: every original is decoded once
let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export");
assert_eq!(files.len(), ids.len());
for &id in &ids {
session.media.forget(id); // every original is decoded once per run
}
});
lightcraft_engine::gpu::set_enabled(true);
+391
View File
@@ -0,0 +1,391 @@
//! Background tasks in flight (issue #345): imports, exports, preview builds, downloads, the face scan… Each one
//! registers here while it runs, so every frontend can show them in one place (the desktop app's activity stack) and
//! cancel the ones that can be cancelled (`activity.list`, `activity.cancel`).
//!
//! A job holds a [`TaskGuard`] for as long as it runs; dropping it — when the job ends, fails or its thread unwinds
//! from a panic — removes the row, so a dead worker never leaves a stuck bar. Jobs keep the cancel flag they already
//! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the
//! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a
//! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use serde::Serialize;
/// The longest label or detail kept, in characters (a file name can be anything).
const MAX_TEXT: usize = 200;
/// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it.
#[derive(Clone, Default)]
pub struct Activity {
inner: Arc<Inner>,
}
#[derive(Default)]
struct Inner {
/// Oldest first.
tasks: Mutex<Vec<Arc<Entry>>>,
next: AtomicU64,
}
/// How a task can be cancelled.
pub enum Cancel {
/// Not at all (no ✕): the face scan, AI Denoise.
No,
/// With a flag of its own ([`TaskGuard::cancel_flag`]).
Yes,
/// With the job's existing flag.
Flag(Arc<AtomicBool>),
}
/// How the frontend words `done` / `total`: "3 of 25", "12 of 340 MB", "40 %".
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Unit {
#[default]
Count,
Bytes,
Percent,
}
impl Unit {
fn from_u8(v: u8) -> Unit {
match v {
1 => Unit::Bytes,
2 => Unit::Percent,
_ => Unit::Count,
}
}
fn to_u8(self) -> u8 {
match self {
Unit::Count => 0,
Unit::Bytes => 1,
Unit::Percent => 2,
}
}
}
/// One task as the frontends see it (`activity.list`).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskInfo {
pub id: u64,
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
/// `faces`, `denoise`, `findMissing`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
pub done: u64,
/// 0 while the amount of work isn't known yet (an indeterminate bar).
pub total: u64,
pub unit: Unit,
/// The file, photo or model being worked on, if any.
pub detail: String,
pub cancellable: bool,
/// Cancel was asked for; the job stops at its next check.
pub cancelling: bool,
pub age_ms: u64,
}
struct Entry {
id: u64,
kind: &'static str,
label: String,
started: web_time::Instant,
done: AtomicU64,
total: AtomicU64,
unit: AtomicU8,
detail: Mutex<String>,
cancel: Option<Arc<AtomicBool>>,
cancellable: AtomicBool,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
fn capped(text: &str) -> String {
text.chars().take(MAX_TEXT).collect()
}
impl Entry {
fn is_cancellable(&self) -> bool {
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
}
fn is_cancelled(&self) -> bool {
self.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))
}
fn progress(&self, done: u64, total: u64) {
self.total.store(total, Ordering::Relaxed);
self.done.store(if total > 0 { done.min(total) } else { done }, Ordering::Relaxed);
}
fn detail(&self, text: &str) {
*lock(&self.detail) = capped(text);
}
fn info(&self) -> TaskInfo {
TaskInfo {
id: self.id,
kind: self.kind,
label: self.label.clone(),
done: self.done.load(Ordering::Relaxed),
total: self.total.load(Ordering::Relaxed),
unit: Unit::from_u8(self.unit.load(Ordering::Relaxed)),
detail: lock(&self.detail).clone(),
cancellable: self.is_cancellable(),
cancelling: self.is_cancelled(),
age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
}
}
}
impl Activity {
/// Register a task; it is listed until the returned guard is dropped. `label` is English (the frontend
/// translates it).
pub fn start(&self, kind: &'static str, label: &str, cancel: Cancel) -> TaskGuard {
let id = self.inner.next.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let cancel = match cancel {
Cancel::No => None,
Cancel::Yes => Some(Arc::new(AtomicBool::new(false))),
Cancel::Flag(flag) => Some(flag),
};
let entry = Arc::new(Entry {
id,
kind,
label: capped(label),
started: web_time::Instant::now(),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
unit: AtomicU8::new(Unit::Count.to_u8()),
detail: Mutex::new(String::new()),
cancellable: AtomicBool::new(cancel.is_some()),
cancel,
});
lock(&self.inner.tasks).push(entry.clone());
TaskGuard { activity: self.clone(), entry }
}
/// Every task in flight, oldest first.
pub fn list(&self) -> Vec<TaskInfo> {
lock(&self.inner.tasks).iter().map(|e| e.info()).collect()
}
/// Ask task `id` to stop (it stops at its next check). An unknown id (the task may have just ended) or a task
/// that can't be cancelled is an error.
pub fn cancel(&self, id: u64) -> Result<(), String> {
let tasks = lock(&self.inner.tasks);
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
match (&entry.cancel, entry.is_cancellable()) {
(Some(flag), true) => {
flag.store(true, Ordering::Relaxed);
Ok(())
}
_ => 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 that quitting would cut short: cancellable ones not already stopping.
pub fn running_cancellable(&self) -> Vec<TaskInfo> {
lock(&self.inner.tasks).iter().filter(|e| e.is_cancellable() && !e.is_cancelled()).map(|e| e.info()).collect()
}
}
/// A task's place in the list, held by whoever owns the job; dropping it removes the row.
pub struct TaskGuard {
activity: Activity,
entry: Arc<Entry>,
}
impl TaskGuard {
pub fn id(&self) -> u64 {
self.entry.id
}
/// `done` of `total` (`total` 0 = not known yet). `done` is kept at most `total`.
pub fn progress(&self, done: u64, total: u64) {
self.entry.progress(done, total);
}
/// The file, photo or model being worked on (a name, not a sentence).
pub fn detail(&self, text: &str) {
self.entry.detail(text);
}
pub fn set_unit(&self, unit: Unit) {
self.entry.unit.store(unit.to_u8(), Ordering::Relaxed);
}
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
pub fn set_cancellable(&self, yes: bool) {
self.entry.cancellable.store(yes, Ordering::Relaxed);
}
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
self.entry.cancel.clone()
}
pub fn is_cancelled(&self) -> bool {
self.entry.is_cancelled()
}
/// For progress from another thread; it does not keep the row.
pub fn handle(&self) -> TaskHandle {
TaskHandle { entry: self.entry.clone() }
}
}
impl Drop for TaskGuard {
fn drop(&mut self) {
let id = self.entry.id;
lock(&self.activity.inner.tasks).retain(|e| e.id != id);
}
}
impl std::fmt::Debug for TaskGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskGuard").field("id", &self.entry.id).field("kind", &self.entry.kind).finish()
}
}
/// Progress for a task from another thread (see [`TaskGuard::handle`]).
#[derive(Clone)]
pub struct TaskHandle {
entry: Arc<Entry>,
}
impl TaskHandle {
pub fn progress(&self, done: u64, total: u64) {
self.entry.progress(done, total);
}
pub fn detail(&self, text: &str) {
self.entry.detail(text);
}
pub fn is_cancelled(&self) -> bool {
self.entry.is_cancelled()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn guard_drop_removes_the_row() {
let a = Activity::default();
let g = a.start("export", "Exporting", Cancel::Yes);
assert_eq!(a.list().len(), 1);
drop(g);
assert!(a.list().is_empty());
}
#[test]
fn guard_drop_on_panic_unwind_removes_the_row() {
let a = Activity::default();
let a2 = a.clone();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _g = a2.start("import", "Importing", Cancel::Yes);
panic!("synthetic worker panic");
}));
assert!(r.is_err());
assert!(a.list().is_empty());
}
#[test]
fn cancel_sets_the_adopted_flag() {
let a = Activity::default();
let flag = Arc::new(AtomicBool::new(false));
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
a.cancel(g.id()).unwrap();
assert!(flag.load(Ordering::Relaxed) && g.is_cancelled());
assert!(a.list()[0].cancelling);
}
#[test]
fn cancel_errors_instead_of_panicking() {
let a = Activity::default();
assert!(a.cancel(42).is_err());
let g = a.start("faces", "Finding faces", Cancel::No);
assert!(a.cancel(g.id()).is_err());
let h = a.start("lightroom", "Importing Lightroom catalog", Cancel::Yes);
h.set_cancellable(false);
assert!(a.cancel(h.id()).is_err());
assert!(!h.is_cancelled());
}
#[test]
fn cancel_all_counts_only_cancellable() {
let a = Activity::default();
let (_x, _y, _z) = (a.start("export", "E", Cancel::Yes), a.start("import", "I", Cancel::Yes), a.start("faces", "F", Cancel::No));
assert_eq!(a.cancel_all(), 2);
assert!(a.running_cancellable().is_empty());
}
#[test]
fn progress_is_clamped_and_detail_capped() {
let a = Activity::default();
let g = a.start("export", "E", Cancel::Yes);
g.progress(12, 10);
g.detail(&"é".repeat(300));
let t = &a.list()[0];
assert_eq!((t.done, t.total), (10, 10));
assert_eq!(t.detail.chars().count(), 200);
g.progress(5, 0); // indeterminate keeps done as given
assert_eq!(a.list()[0].total, 0);
assert_eq!(a.list()[0].unit, Unit::Count);
g.set_unit(Unit::Bytes);
assert_eq!(a.list()[0].unit, Unit::Bytes);
}
#[test]
fn ids_are_unique_and_list_is_oldest_first() {
let a = Activity::default();
let (g1, g2) = (a.start("export", "A", Cancel::Yes), a.start("import", "B", Cancel::Yes));
assert!(g2.id() > g1.id());
assert_eq!(a.list().iter().map(|t| t.id).collect::<Vec<_>>(), vec![g1.id(), g2.id()]);
}
#[test]
fn poisoned_lock_still_works() {
let a = Activity::default();
let a2 = a.clone();
let _ = std::thread::spawn(move || {
let _held = a2.inner.tasks.lock().unwrap();
panic!("synthetic panic while holding the task list");
})
.join();
assert!(a.inner.tasks.is_poisoned());
let _g = a.start("export", "E", Cancel::Yes);
assert_eq!(a.list().len(), 1);
}
#[test]
fn handle_updates_from_another_thread() {
let a = Activity::default();
let g = a.start("previews", "Building previews", Cancel::Yes);
let h = g.handle();
std::thread::spawn(move || h.progress(3, 7)).join().unwrap();
assert_eq!((a.list()[0].done, a.list()[0].total), (3, 7));
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
+65
View File
@@ -0,0 +1,65 @@
//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "activity.list",
"Background Tasks",
[],
None,
"{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet",
always,
|s, _| Ok(json!({"tasks": s.activity.list()}))
),
cmd!(
"activity.cancel",
"Cancel Background Task",
[],
None,
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error",
always,
|s, p| {
const C: &str = "activity.cancel";
if p.get("all").and_then(Value::as_bool) == Some(true) {
return Ok(json!({"cancelled": s.activity.cancel_all()}));
}
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;
s.activity.cancel(id).map_err(|e| bad(C, e))?;
Ok(json!({"cancelled": 1}))
}
),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
use crate::activity::Cancel;
#[test]
fn list_and_cancel_through_execute() {
let mut s = Session::new();
let g = s.activity.start("export", "Exporting", Cancel::Yes);
let r = s.execute("activity.list", &json!({})).unwrap();
assert_eq!(r["tasks"][0]["kind"], "export");
assert_eq!(r["tasks"][0]["cancellable"], true);
let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap();
assert_eq!(r["cancelled"], 1);
assert!(g.is_cancelled());
}
#[test]
fn cancel_all_and_errors() {
let mut s = Session::new();
let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No));
assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1);
assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err());
assert!(s.execute("activity.cancel", &json!({})).is_err());
}
}
+3 -1
View File
@@ -286,7 +286,7 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
return Err("the library's own folders can't be moved here".into());
}
rename_folder_on_disk(from, to)?;
let ops: Vec<Op> = s
let mut ops: Vec<Op> = s
.catalog
.photos()
.filter_map(|p| match &p.source {
@@ -301,6 +301,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
})
.collect();
let n = ops.len();
// what the library knows about the folder (and those inside it) goes along, in the same step
ops.extend(s.catalog.folder_records_follow(from, to));
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let renamed = src.parent() == dst.parent();
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
+128 -13
View File
@@ -210,6 +210,25 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
ops
}
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
/// it already has them. A catalog from before the lens data was read at import has none, so such a
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
/// its settings.
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
if info.embedded_lens == ph.embedded_lens {
return Vec::new();
}
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
// (a photo with a default-preset look keeps it: that look is its import state)
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
let mut d = (*ph.develop).clone();
d.optics.lens_profile = true;
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
}
ops
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@@ -229,21 +248,19 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let Some(op) = content_op(id, ph, info.clone()) else {
if !meta_ops.is_empty() {
ops.extend(meta_ops);
reloaded.push(id);
}
continue;
};
ops.push(op);
ops.extend(meta_ops);
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
reloaded.push(id);
}
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.extend(content_op(c.id, c, info.clone()));
reloaded.push(c.id);
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
reloaded.push(id);
}
for id in &reloaded {
s.media.forget(*id);
@@ -316,7 +333,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Reload from Disk",
["Photo"],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
has_selection,
reload
),
@@ -359,4 +376,102 @@ mod tests {
ph.captured = Some("2026-10-04T08:16:11".into());
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
}
fn lens() -> lightcraft_develop::EmbeddedLens {
lightcraft_develop::EmbeddedLens {
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
vignette: None,
}
}
fn old_rw2() -> lightcraft_catalog::Photo {
// as an older build catalogued it: no lens data, correction off
lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(1),
Source::File { path: "/x.rw2".into() },
"x.rw2",
"RW2",
4592,
3448,
"2026-10-06T00:00:00",
)
}
#[test]
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
let ph = old_rw2();
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
let ops = lens_ops(ph.id, &ph, &info);
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
assert!(settings.optics.lens_profile);
// once applied, the photo is still "unedited" and has what import would have given it
let mut c = lightcraft_catalog::Catalog::default();
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
for op in ops {
c.apply(op).unwrap();
}
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
assert_eq!(after.embedded_lens, Some(lens()));
assert!(!after.is_edited());
assert_eq!(*after.develop, after.camera_defaults());
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
}
#[test]
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
let mut ph = old_rw2();
let mut d = (*ph.develop).clone();
d.light.exposure = 0.7;
ph.develop = std::sync::Arc::new(d);
assert!(ph.is_edited());
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
let ops = lens_ops(ph.id, &ph, &info);
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
}
#[test]
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
let mut ph = old_rw2();
ph.embedded_lens = Some(lens());
let mut d = ph.camera_defaults();
d.optics.lens_profile = false;
ph.develop = std::sync::Arc::new(d);
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
assert!(lens_ops(ph.id, &ph, &info).is_empty());
}
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
#[test]
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
let f = root.join("raw/rw2-panasonic-gx80.rw2");
if !f.exists() {
eprintln!("skip: {} absent", f.display());
return;
}
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
let ph = s.catalog.photo(id).unwrap().clone();
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
// back to how 0.4.0 catalogued it
let mut old = (*ph.develop).clone();
old.optics.lens_profile = false;
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
s.media.forget(id);
let before = s.render_now(id, 160, 120).unwrap().image.data;
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]));
let now = s.catalog.photo(id).unwrap().clone();
assert_eq!(now.embedded_lens, ph.embedded_lens);
assert!(now.develop.optics.lens_profile && !now.is_edited());
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
s.execute("edit.undo", &json!({})).unwrap();
let undone = s.catalog.photo(id).unwrap();
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
}
}
+2 -1
View File
@@ -368,7 +368,7 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "that model is already installed"));
}
let host = lightcraft_denoise::known::host(&spec.url).to_string();
s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?;
Ok(json!({"started": id, "from": host}))
}
@@ -614,6 +614,7 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
s.denoise_track();
Ok(json!({
"active": s.denoise.active.is_some(),
"running": s.denoise_running_json(),
+104 -2
View File
@@ -1,7 +1,7 @@
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
@@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
fn outdated(s: &Session, id: PhotoId) -> bool {
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
}
fn already_current() -> String {
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
}
/// Update to Current Process applies to the selection (the selected photos, else the active
/// photo): one of them is on an older rendering process. Calls that name their photos are
/// checked by the command instead (`explicit_targets`).
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let sel = &s.selection;
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
}
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
/// names none (the selection applies).
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
if !super::names_photos(p) {
return Ok(None);
}
let given = match p.get("ids").filter(|v| !v.is_null()) {
Some(Value::Array(a)) => a.clone(),
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
None => p.get("id").cloned().into_iter().collect(),
};
if given.is_empty() {
return Err(bad(c, "`ids` names no photos"));
}
let ids = given
.iter()
.map(|v| match v.as_u64().map(PhotoId) {
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
_ => Err(bad(c, format!("no such photo {v}"))),
})
.collect::<Result<Vec<_>>>()?;
Ok(Some(ids))
}
/// The user preset named by `p.id` (built-ins can't be changed).
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
@@ -203,12 +246,44 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
};
s.develop_op(id, fresh, "Reset")
// a fresh start renders with the current process (a section or slider reset doesn't change it)
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
})
.collect();
s.commit("Reset", Op::Batch { ops })?;
ok()
}),
CommandSpec {
explicit_targets: true,
..cmd!(
"develop.updateProcess",
"Update to Current Process",
["Photo"],
None,
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
has_outdated_process,
|s, p| {
const C: &str = "develop.updateProcess";
const LABEL: &str = "Update to Current Process";
let targets = match named_photos(s, p, C)? {
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
Some(ids) => ids,
None => s.targets(p),
};
let ops: Vec<Op> = targets
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter(|(_, d)| d.process.is_outdated())
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
.collect();
let n = ops.len();
if n > 0 {
s.commit(LABEL, Op::Batch { ops })?;
}
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
}
)
},
cmd!(
"develop.resetSection",
"Reset Section",
@@ -572,6 +647,33 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
// ---- HDR editing
cmd!(
"develop.hdr",
"HDR",
[],
None,
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
has_active,
|s, p| {
let id = active(s, "develop.hdr")?;
let cur = s.develop_of(id).unwrap_or_default().hdr;
let enabled = bool_or(p, "enabled", !cur.enabled);
let max_ev = match p.get("maxEv") {
None => None,
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
};
let label = if enabled { "HDR On" } else { "HDR Off" };
edit(s, "develop.hdr", label, |d| {
d.hdr.enabled = enabled;
if let Some(m) = max_ev {
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
}
Ok(())
})?;
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
}
),
// ---- geometry: Upright
cmd!(
"geometry.upright",
+11
View File
@@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
always, |s, p| {
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
}
),
cmd!(
"export.presets",
"Export Presets",
+7 -1
View File
@@ -356,7 +356,13 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "that model is already installed"));
}
let host = spec.host().to_string();
s.face_downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
let name = known::all()
.into_iter()
.find(|m| m.id == spec.id)
.map(|m| m.name)
.or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == spec.id).map(|e| e.manifest.name.clone()))
.unwrap_or_else(|| spec.id.clone());
s.face_downloads.start(spec, &dir, &s.activity, &name).map_err(|e| bad(C, e))?;
Ok(json!({"started": id, "from": host}))
}
+22 -1
View File
@@ -515,6 +515,21 @@ mod imp {
}))
}
/// The scan's row in the activity stack: there while `pending` photos are left, its whole the most that were left at
/// once since the scan last finished (which it returns; 0 when none are left). It can't be stopped from there: the
/// scan is switched off in Settings.
fn track(s: &mut Session, pending: u64) -> u64 {
if pending == 0 {
s.faces.task = None;
s.faces.peak = 0;
return 0;
}
s.faces.peak = s.faces.peak.max(pending);
let task = s.faces.task.get_or_insert_with(|| s.activity.start("faces", "Finding faces", crate::activity::Cancel::No));
task.progress(s.faces.peak.saturating_sub(pending), s.faces.peak);
s.faces.peak
}
/// `faces.pump`: cheap to call every frame. Takes in what the background worker has finished and, when it is idle,
/// hands it the next photo with faces to embed. Does nothing (and says so) until recognition is switched on in
/// Settings with a model chosen.
@@ -528,14 +543,17 @@ mod imp {
finish_downloads(s);
let enabled = s.face_models_dir.clone().is_some_and(|d| enabled_cached(s, &d));
if !enabled {
track(s, 0);
return Ok(json!({"active": false}));
}
if s.faces.retry_at.is_some_and(|t| Instant::now() < t) {
track(s, 0);
return Ok(json!({"active": false}));
}
let Ok(embedder) = current_with(s, C, false) else {
// the chosen model is gone or broken: look again in a few seconds, not every frame
s.faces.retry_at = Some(Instant::now() + Duration::from_secs(3));
track(s, 0);
return Ok(json!({"active": false}));
};
s.faces.retry_at = None;
@@ -573,10 +591,13 @@ mod imp {
break;
}
}
let pending = s.faces.queue.len() + s.faces.in_flight.len();
let peak = track(s, pending as u64);
Ok(json!({
"active": true,
"inFlight": s.faces.in_flight.len(),
"pendingPhotos": s.faces.queue.len() + s.faces.in_flight.len(),
"pendingPhotos": pending,
"peak": peak,
"indexedFaces": s.faces.index.len(),
"searchedPhotos": s.faces.scanned.len(),
"workers": target,
+319 -31
View File
@@ -1,10 +1,10 @@
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
use lightcraft_catalog::keywords::{clean, is_under};
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
use crate::{Result, Session};
fn strs(p: &Value, key: &str) -> Vec<String> {
@@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
}
}
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
/// keyword and is cleared when its keyword is deleted.
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
let n = match &op {
lightcraft_catalog::Op::Batch { ops } => ops.len(),
_ => 1,
use lightcraft_catalog::Op;
let (photos, listed) = match &op {
Op::Batch { ops } => {
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
}
Op::SetMeta { .. } => (1, 0),
_ => (0, 1),
};
if n > 0 {
if photos + listed > 0 {
s.commit(label, op)?;
}
if let Some(k) = s.filter.keyword.clone() {
s.filter.keyword = follow(&k);
}
Ok(json!({"changed": n}))
Ok(json!({"changed": photos, "listed": listed}))
}
/// A keyword's attributes from command params over `base`: those not given keep their value.
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
KeywordInfo {
synonyms: if p.get("synonyms").is_some() {
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
} else {
base.synonyms
},
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
export_containing: bool_or(p, "exportContaining", base.export_containing),
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
person: bool_or(p, "person", base.person),
new_keywords_inside: base.new_keywords_inside,
}
}
/// The keyword a command names (`keyword`), as the library writes it.
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
}
/// A catalog refusal as a command error; a taken name says how to merge.
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
match e {
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
e => bad(command, e.to_string()),
}
}
/// A named set of up to nine keywords.
@@ -38,6 +72,12 @@ pub struct KeywordSet {
pub keywords: Vec<String>,
}
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
/// keywords are.
fn same_set(a: &str, b: &str) -> bool {
lightcraft_catalog::keywords::same(a.trim(), b.trim())
}
/// The set name meaning "the nine most recently added keywords".
pub const RECENT: &str = "Recent Keywords";
@@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
if k.is_empty() {
continue;
}
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
s.recent_keywords.retain(|x| !same_set(x, &k));
s.recent_keywords.insert(0, k);
}
s.recent_keywords.truncate(9);
let _ = s.save_prefs();
}
/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB).
const MAX_LIST_BYTES: u64 = 16 << 20;
/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here).
const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>'];
/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark).
fn read_list(path: &str) -> Result<String> {
use std::io::Read;
let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
let mut bytes = Vec::new();
file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
if bytes.len() as u64 > MAX_LIST_BYTES {
return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
}
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
}
String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text")))
}
/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others
/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty),
/// nine at most, no empty ones at the end.
pub fn slots(typed: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for k in typed.iter().take(9) {
let k = clean(k);
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
out.push(if twice { String::new() } else { k });
}
while out.last().is_some_and(String::is_empty) {
out.pop();
}
out
}
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
pub fn current_keywords(s: &Session) -> Vec<String> {
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
v.truncate(9);
v
@@ -75,13 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
None
} else {
Some(
s.keyword_sets
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.find(|x| same_set(&x.name, name))
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
.name
.clone(),
@@ -95,24 +173,53 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Keyword Set",
[],
None,
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
always,
|s, p| {
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
.to_string();
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
// the set it renames, if any (an empty `replace` is none)
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
Some(old) => Some(
s.keyword_sets
.iter()
.position(|x| same_name(x, old))
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
),
None => None,
};
// keywords not given: the renamed set's own, else the current nine
let keywords = if p.get("keywords").is_some() {
slots(&strs(p, "keywords"))
} else {
current_keywords(s)
match renames.and_then(|at| s.keyword_sets.get(at)) {
Some(x) => x.keywords.clone(),
None => current_keywords(s),
}
};
keywords.truncate(9);
let set = KeywordSet { name: name.clone(), keywords };
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
match renames {
// rename: in its place, onto a name no other set has
Some(at) => {
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
}
if let Some(x) = s.keyword_sets.get_mut(at) {
*x = set;
}
}
// a new set (`new`) never takes another set's name
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
}
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
},
}
s.keyword_set = Some(name);
s.save_prefs()?;
@@ -122,11 +229,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
let before = s.keyword_sets.len();
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
s.keyword_sets.retain(|x| !same_set(&x.name, name));
if s.keyword_sets.len() == before {
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
}
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
s.keyword_set = None;
}
s.save_prefs()?;
@@ -145,12 +252,17 @@ pub fn specs() -> Vec<CommandSpec> {
.and_then(Value::as_u64)
.filter(|i| (1..=9).contains(i))
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
// (an empty slot does nothing)
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
return Ok(json!({"changed": 0}));
};
let ids = s.targets(p);
let all = !ids.is_empty()
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
&& ids.iter().all(|id| {
s.catalog
.photo(*id)
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
});
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
let key = if all { "removeKeywords" } else { "addKeywords" };
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
@@ -185,6 +297,184 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!(s.catalog.keyword_suggestions(&current, str_param(p, "prefix").unwrap_or(""), n)))
}
),
cmd!(
query "keyword.info",
"Keyword Info",
[],
None,
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
always,
|s, p| {
let path = named_keyword(s, p, "keyword.info")?;
let info = s.catalog.keyword_info(&path).cloned();
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
if let Some(o) = v.as_object_mut() {
o.insert("path".into(), json!(path));
o.insert("listed".into(), json!(info.is_some()));
o.insert("count".into(), json!(count));
o.entry("synonyms").or_insert(json!([]));
}
Ok(v)
}
),
cmd!(
"keyword.create",
"Create Keyword",
[],
None,
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
always,
|s, p| {
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
let parent = match p.get("parent") {
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
Some(_) => None,
None => s.catalog.default_keyword_parent(),
};
let path = clean(&match parent {
Some(parent) => format!("{parent}|{name}"),
None => name,
});
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
let op = s
.catalog
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
.map_err(|e| refused("keyword.create", e, ""))?;
s.commit("Create Keyword", op)?;
if !photos.is_empty() {
note_recent(s, std::slice::from_ref(&path));
}
Ok(json!({"keyword": path}))
}
),
cmd!(
"keyword.edit",
"Edit Keyword",
[],
None,
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
always,
|s, p| {
let from = named_keyword(s, p, "keyword.edit")?;
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
let op =
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
let to = match from.rsplit_once('|') {
Some((parent, _)) => format!("{parent}|{}", name.trim()),
None => name.trim().to_string(),
};
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
}
),
cmd!(
"keyword.move",
"Move Keyword",
[],
None,
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
always,
|s, p| {
let from = named_keyword(s, p, "keyword.move")?;
let parent = match p.get("parent") {
Some(Value::String(k)) => Some(k.clone()),
Some(Value::Null) => None,
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
};
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
Some(parent) => format!("{parent}|{leaf}"),
None => leaf,
};
let op = s
.catalog
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
}
),
cmd!(
"keyword.purgeUnused",
"Purge Unused Keywords",
[],
None,
"{} — takes off the keyword list the keywords no photo has → {purged}",
always,
|s, _| {
let op = s.catalog.purge_unused_keywords_ops();
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
Ok(json!({"purged": r["listed"]}))
}
),
cmd!(
"keyword.setDefaultParent",
"Put New Keywords Inside",
[],
None,
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
always,
|s, p| {
let keyword = match p.get("keyword") {
Some(Value::Null) | None => None,
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
};
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
Ok(json!({"keyword": s.catalog.default_keyword_parent()}))
}
),
cmd!(
"keyword.export",
"Export Keywords",
[],
None,
"{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}",
always,
|s, p| {
let file = s.catalog.keyword_list_text();
// Capture One's importer refuses ; , < > in a keyword's name
let refuses: Vec<&str> = file
.written
.iter()
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
.collect();
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
Some(path) => {
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
r["path"] = json!(path);
}
None => r["text"] = json!(file.text),
}
Ok(r)
}
),
cmd!(
"keyword.import",
"Import Keywords",
[],
None,
"{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}",
always,
|s, p| {
let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) {
(Some(path), _) => read_list(path)?,
(None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => {
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
}
(None, Some(text)) => text.to_string(),
(None, None) => return Err(bad("keyword.import", "give `path` or `text`")),
};
let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?;
let (op, added, updated) = s.catalog.import_keywords_ops(&entries);
commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?;
Ok(json!({"added": added, "updated": updated}))
}
),
cmd!(
"keyword.rename",
"Rename Keyword",
@@ -197,9 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
let (f, t) = (clean(&from), clean(&to));
commit_keywords(s, "Rename Keyword", op, |k| {
Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() })
})
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
}
),
cmd!(
@@ -229,8 +517,8 @@ pub fn specs() -> Vec<CommandSpec> {
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
commit_keywords(s, "Merge Keywords", op, |k| {
Some(match from.iter().find(|f| is_under(k, f)) {
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
Some(match closest(k, &from) {
Some(f) => reparent(k, f, &into),
None => k.to_string(),
})
})
+205 -41
View File
@@ -7,6 +7,20 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
}
/// The auto-import folder's visible files with their sizes (the file-system half of
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
@@ -146,6 +160,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
Ok(json!({"changed": n}))
}
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p) {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
if rating != old {
ops.push(Op::SetRating { id, rating });
}
}
let n = ops.len();
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
advance_if(s, p);
Ok(json!({"changed": n}))
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
@@ -330,6 +362,16 @@ pub fn specs() -> Vec<CommandSpec> {
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
}
// checked like a smart album's rules when this call sets them: an unknown field or a value
// that can't match is an error, not an empty grid. Rules that stopped checking since
// (their album was deleted) don't block other filter changes.
if let Some(rules) = f.rule_set.as_mut() {
rules.upgrade();
}
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
}
s.filter = f;
Ok(json!({"count": s.visible().len()}))
}
@@ -339,7 +381,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library Folders",
[],
None,
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
always,
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
),
@@ -387,6 +429,33 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"removed": ids.len()}))
}
),
cmd!(
"folder.label",
"Set Folder Color Label",
[],
None,
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
always,
|s, p| {
const C: &str = "folder.label";
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
"none" => None,
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
};
// taking a label off always works, also once the folder's photos are gone
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
}
if label.is_none() && s.catalog.folder_record(path).is_none() {
return Ok(json!({"path": path, "label": label}));
}
let op = s.catalog.folder_label_op(path, label);
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
Ok(json!({"path": path, "label": label}))
}
),
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
s.filter = Default::default();
Ok(json!({"count": s.visible().len()}))
@@ -564,6 +633,12 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, -1, "Decrease Rating")
}),
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
change_rating(s, p, 1, "Increase Rating")
}),
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
@@ -662,18 +737,8 @@ pub fn specs() -> Vec<CommandSpec> {
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
select_after_deletion(s, &before);
Ok(v)
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
@@ -699,10 +764,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let before = s.visible_cloned();
let t = s.targets(p);
let op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
s.selection = Selection::default();
select_after_deletion(s, &before);
Ok(json!({"deleted": t.len()}))
}),
// ---- metadata
@@ -766,16 +832,26 @@ pub fn specs() -> Vec<CommandSpec> {
*field = v.to_string();
}
}
if let Some(k) = strs("keywords") {
m.keywords = k;
}
for k in strs("addKeywords").unwrap_or_default() {
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
use lightcraft_catalog::keywords::{clean, same};
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
let k = clean(k);
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
m.keywords.push(k);
}
};
if let Some(k) = strs("keywords") {
m.keywords.clear();
for k in k {
add(&mut m, &k);
}
}
for k in strs("addKeywords").unwrap_or_default() {
add(&mut m, &k);
}
for k in strs("removeKeywords").unwrap_or_default() {
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
let k = clean(&k);
m.keywords.retain(|x| !same(&clean(x), &k));
}
ops.push(Op::SetMeta { id, meta: Box::new(m) });
}
@@ -819,7 +895,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("album.createSmart", "empty name"));
}
let rules = match p.get("rules") {
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
None => view_rules(s),
};
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
@@ -829,8 +905,8 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
}
),
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
Ok(json!(FIELDS
.iter()
.map(|(id, label, kind)| {
@@ -838,13 +914,15 @@ pub fn specs() -> Vec<CommandSpec> {
Kind::Text => "text",
Kind::Keywords => "keywords",
Kind::Number => "number",
Kind::Album => "album",
Kind::Date => "date",
Kind::Choice(_) => "choice",
Kind::Bool => "bool",
};
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
if let Kind::Choice(c) = kind {
v["choices"] = json!(c);
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
}
v
})
@@ -855,18 +933,34 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Smart Album",
[],
None,
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
always,
|s, p| {
let id = album_param(p, "id", "album.setRules")?;
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
let rules = if bool_or(p, "fromView", false) {
view_rules(s)
// the view is saved as it is, but never as a loop (showing A, which tests this
// album, and saving that view here would make it test itself)
let view = view_rules(s);
let loops: Vec<String> = view
.rule_set
.as_ref()
.map(|r| r.check_for(&s.catalog, Some(id)))
.unwrap_or_default()
.into_iter()
.chain(s.catalog.album_filter_problem(&view, Some(id)))
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
.map(|p| p.to_string())
.collect();
if !loops.is_empty() {
return Err(bad("album.setRules", loops.join("; ")));
}
view
} else {
let replace = bool_or(p, "replace", false);
let folder = cur.library_folder.clone();
let base = if replace { Default::default() } else { cur };
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
// the rules dialog has no folder field: replacing its rules keeps the folder
// unless the call says (`libraryFolder: null`) to drop it
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
@@ -874,7 +968,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
r
};
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
// a new name goes with the rules: one step, applied (and undone) together
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
match rename {
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
None => s.commit("Edit Smart Album", set)?,
};
Ok(json!({"count": s.catalog.album_count(id)}))
}
),
@@ -884,14 +984,45 @@ pub fn specs() -> Vec<CommandSpec> {
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
ok()
}),
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
let id = album_param(p, "id", "album.delete")?;
s.commit("Delete Album", Op::RemoveAlbum { id })?;
if s.source == LibrarySource::Album(id) {
s.source = LibrarySource::All;
cmd!(
"album.delete",
"Delete Album",
[],
None,
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
always,
|s, p| {
let id = album_param(p, "id", "album.delete")?;
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
for album in s.catalog.albums() {
if let Some(parent) = album.parent {
children.entry(parent).or_default().push(album.id);
}
}
// Iterative traversal: even a malformed cycle is rejected before any change.
// Reverse parent-first order removes children first; the batch inverse restores
// parents first, including memberships, smart rules, covers and manual ordering.
let mut pending = vec![id];
let mut order = Vec::new();
let mut removed = std::collections::BTreeSet::new();
while let Some(next) = pending.pop() {
if !removed.insert(next) {
return Err(bad("album.delete", "cycle in album folders"));
}
order.push(next);
if let Some(nested) = children.remove(&next) {
pending.extend(nested);
}
}
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
if matches!(s.source, LibrarySource::Album(current) if removed.contains(&current)) {
s.source = LibrarySource::All;
}
ok()
}
ok()
}),
),
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
let id = album_param(p, "id", "album.move")?;
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
@@ -1283,12 +1414,39 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// `base` with a partial Filter (JSON) merged on top.
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
/// [`merge_rules`]).
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
let mut v = serde_json::to_value(base).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, patch);
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
return Err(bad(c, problem));
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if let Some(rules) = f.rule_set.as_mut() {
rules.upgrade();
}
Ok(f)
}
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
/// problem too).
fn merge_rules(
base: &lightcraft_catalog::Filter,
patch: &Value,
c: &str,
cat: &lightcraft_catalog::Catalog,
owner: Option<AlbumId>,
) -> Result<lightcraft_catalog::Filter> {
let f = merge_filter(base, patch, c)?;
// only rules this change sets are checked: one that stopped checking since (its album was
// deleted) doesn't block editing the album's other settings
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
// and a loop through the album field the change sets
if patch.get("album").is_some_and(|a| !a.is_null()) {
problems.extend(cat.album_filter_problem(&f, owner));
}
if !problems.is_empty() {
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
}
Ok(f)
}
@@ -1296,7 +1454,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
/// one disk but a way to reach several.
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
ids.iter()
.filter_map(|id| s.catalog.photo(*id))
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
@@ -1324,7 +1482,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
}
}
}
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
// the view as it is: a rule that no longer checks (its album was deleted) just matches
// nothing there, and mustn't cost the filter bar's settings
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
}
None => {
let mut f = s.source.to_filter(&s.filter, &s.catalog);
@@ -1392,3 +1552,7 @@ mod gps_tests {
assert_eq!(p("hello"), None);
}
}
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
@@ -0,0 +1,102 @@
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-09",
);
photo.rating = rating;
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
}
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
Ok(s)
}
fn ratings(s: &Session) -> Result<Vec<u8>> {
(1..=4)
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
.collect()
}
#[test]
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
}
#[test]
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
assert!(s.undo.is_empty());
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
let undo = s.undo.len();
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
assert_eq!(s.undo.len(), undo);
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
assert_eq!(s.active(), Some(PhotoId(3)));
s.selection = Selection::default();
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
}
#[test]
fn relative_ratings_and_history_use_auto_write() {
let mut s = session().unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
file_name: format!("{id}.jpg"),
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
})
.unwrap();
}
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
s.execute("photo.increaseRating", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
s.execute("edit.undo", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
s.execute("photo.decreaseRating", &json!({})).unwrap();
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
std::fs::remove_dir_all(dir).unwrap();
}
+27 -6
View File
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
// photos without a capture time start from their import time
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
if shift.is_nan() || shift.abs() > MAX_SHIFT {
return Err(bad(c, "the shift is more than 10,000 years"));
}
let mut delta = shift.round() as i64;
let mut each: Option<String> = None;
if let Some(t) = str_param(p, "time") {
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
@@ -197,23 +203,38 @@ pub fn specs() -> Vec<CommandSpec> {
each = Some(t);
} else {
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
let anchor_date = base(s, anchor);
// An impossible calendar date cannot anchor changes to the other photos.
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
.and_then(|_| iso_seconds(&anchor_date))
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
delta += iso_seconds(&t).unwrap_or(from) - from;
}
}
let mut ops = Vec::new();
let mut out = Vec::new();
let mut out_of_range = 0usize;
for id in &targets {
let new = match &each {
Some(t) => shift_iso(t, delta),
None => shift_iso(&base(s, *id), delta),
let from = match &each {
Some(t) => t.clone(),
None => base(s, *id),
};
let Some(new) = shift_iso(&from, delta) else {
// a date that reads but would leave years 0000–9999 is refused below; one
// that doesn't read is skipped, as before
if iso_seconds(&from).is_some() {
out_of_range += 1;
}
continue;
};
let Some(new) = new else { continue };
out.push(json!(new));
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
}
}
if out_of_range > 0 {
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
}
let n = ops.len();
if n > 0 {
s.commit("Edit Capture Time", Op::Batch { ops })?;
+2 -2
View File
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let m = &mut masks[i];
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
@@ -464,7 +464,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
));
}
let started = s.segmenter.start_download().map_err(ai_err)?;
let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?;
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
}
),
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
}
fn relink_op(id: PhotoId, path: &str) -> Op {
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
}
+15 -2
View File
@@ -4,6 +4,7 @@
//! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`,
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
mod activity;
mod before;
pub(crate) mod browse;
mod color;
@@ -31,6 +32,7 @@ mod prefs;
mod preset_files;
pub mod previews;
mod query;
mod sync;
mod xmp;
use serde::Serialize;
@@ -54,6 +56,10 @@ pub struct CommandSpec {
pub run: Run,
/// Record in the journal (false for queries).
pub journal: bool,
/// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` /
/// `id`, see [`names_photos`]) skips it, and the command validates those photos itself.
/// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`).
pub explicit_targets: bool,
}
#[derive(Clone, Debug, Serialize)]
@@ -107,10 +113,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false }
};
}
pub(crate) use cmd;
@@ -142,12 +148,14 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(filters::specs());
v.extend(denoise::specs());
v.extend(face_models::specs());
v.extend(face_detect::specs());
v.extend(face_recognize::specs());
v.extend(activity::specs());
v
})
}
@@ -158,6 +166,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
// ---------- param helpers
/// The call names its target photos (`ids` or `id`, not null) instead of using the selection.
pub(crate) fn names_photos(p: &Value) -> bool {
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
}
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
+128 -23
View File
@@ -1,7 +1,7 @@
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
//! with `wait` (CLI, MCP, tests).
//! Runs on a background thread (the app keeps working; progress / cancel by command, and a row in
//! the activity stack, issue #345), or inline with `wait` (CLI, MCP, tests).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::activity::{Cancel, TaskGuard};
use crate::media::{RenderJob, THUMB_SIZES};
use crate::{Result, Session};
@@ -21,7 +22,8 @@ pub struct PreviewBuild {
pub total: usize,
pub done: AtomicUsize,
pub failed: AtomicUsize,
pub cancel: AtomicBool,
/// Shared with the build's row in the activity stack (its ✕ sets it).
pub cancel: Arc<AtomicBool>,
pub finished: AtomicBool,
/// Damaged smart previews rebuilt (Build Smart Previews only).
pub repaired: AtomicUsize,
@@ -62,7 +64,19 @@ fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize
v
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]).
fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard {
let (kind, label) = match state.what {
"" => ("previews", "Building previews"),
"smart previews" => ("smartPreviews", "Building smart previews"),
_ => ("smartPreviews", "Discarding smart previews"),
};
let task = s.activity.start(kind, label, Cancel::Flag(state.cancel.clone()));
task.progress(0, state.total as u64);
task
}
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard) {
for photo_jobs in jobs {
if state.cancel.load(Ordering::Relaxed) {
break;
@@ -71,7 +85,8 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
if !ok {
state.failed.fetch_add(1, Ordering::Relaxed);
}
state.done.fetch_add(1, Ordering::Relaxed);
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
task.progress(done as u64, state.total as u64);
}
state.finished.store(true, Ordering::Relaxed);
}
@@ -91,26 +106,35 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
s.preview_build = Some(state.clone());
let task = task_for(s, &state);
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
if wait {
run_all(jobs, &state);
run_all(jobs, &state, &task);
return Ok(state.json());
}
#[cfg(not(target_arch = "wasm32"))]
{
// the row stays while the worker runs (it is dropped with the closure, however that ends)
let st = state.clone();
std::thread::Builder::new()
.name("lc-build-previews".into())
.spawn(move || run_all(jobs, &st))
.spawn(move || run_all(jobs, &st, &task))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
Ok(state.json())
}
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
/// how to load the original at preview size).
/// how to load the original at preview size, whether the photo takes the per-file camera look —
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those
/// proxies go stale when the fit changes).
#[cfg(not(target_arch = "wasm32"))]
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once.
#[cfg(not(target_arch = "wasm32"))]
static SMART_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// What Build / Discard Smart Previews did.
#[cfg(not(target_arch = "wasm32"))]
@@ -120,11 +144,29 @@ struct SmartCounts {
built: usize,
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
repaired: usize,
/// Of those, proxies written by an older camera-look fit that were rebuilt from their online originals.
refreshed: usize,
/// Of those kept as they were: out of date, but the original can't be read now (offline), so
/// the stored look stays and the next run with the original online rebuilds them.
stale_kept: usize,
removed: usize,
/// `[[id, why]]`
failed: Vec<Value>,
}
/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart
/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy
/// fallback of a file source are not the original.
#[cfg(not(target_arch = "wasm32"))]
fn original_only(source: crate::media::SourceRef) -> Option<crate::media::SourceRef> {
use crate::media::SourceRef;
match source {
SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }),
SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)),
_ => None,
}
}
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
/// recreated. With `state`, progress and cancel.
@@ -134,7 +176,7 @@ fn smart_run(
custom: bool,
discard: bool,
jobs: Vec<SmartJob>,
state: Option<&PreviewBuild>,
state: Option<(&PreviewBuild, &TaskGuard)>,
) -> std::result::Result<SmartCounts, String> {
let mut n = SmartCounts::default();
if !discard {
@@ -145,21 +187,27 @@ fn smart_run(
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
}
for (id, path, source) in jobs {
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
for (id, path, source, look) in jobs {
if state.is_some_and(|(st, _)| st.cancel.load(Ordering::Relaxed)) {
break;
}
let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if discard {
if std::fs::remove_file(&path).is_ok() {
n.removed += 1;
}
} else {
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
// drive) is (re)built
// a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a
// full drive) is (re)built, and so is one written by an older camera-look fit (see
// `camera_preview::LOOK_VERSION`) when its original can be read
let damaged = path.exists();
if damaged && crate::smart::is_valid(&path) {
let stale = look && damaged && crate::smart::is_stale(&path);
if damaged && !stale && crate::smart::is_valid(&path) {
n.built += 1;
} else {
// a stale proxy is rebuilt from the original only: never from itself (the file
// fallback or a source decoded from a proxy would stamp the old look as current)
let source = if stale { source.and_then(original_only) } else { source };
// atomic: a failed write leaves no partial proxy that would pass for a built one;
// and synced (issue #134): built so the photo can be edited while its original
// is offline, when it is the only copy — the sync is small next to the decode
@@ -171,24 +219,33 @@ fn smart_run(
match r {
Ok(()) => {
n.built += 1;
if damaged {
if stale {
n.refreshed += 1;
} else if damaged {
n.repaired += 1;
if let Some(st) = state {
if let Some((st, _)) = state {
st.repaired.fetch_add(1, Ordering::Relaxed);
}
}
}
Err(e) if stale => {
// offline (or unreadable) original: the proxy is all there is, keep its look
log::info!("smart preview {} keeps its older look: {e}", path.display());
n.built += 1;
n.stale_kept += 1;
}
Err(e) => {
n.failed.push(json!([id.0, e]));
if let Some(st) = state {
if let Some((st, _)) = state {
st.failed.fetch_add(1, Ordering::Relaxed);
}
}
}
}
}
if let Some(st) = state {
st.done.fetch_add(1, Ordering::Relaxed);
if let Some((st, task)) = state {
let done = st.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
task.progress(done as u64, st.total as u64);
}
}
Ok(n)
@@ -215,18 +272,19 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
}
let path = dir.join(crate::smart::file_name(&ph));
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
jobs.push((id, path, source));
jobs.push((id, path, source, ph.relative_wb()));
}
let custom = s.smart_previews_dir.is_some();
if background {
let what = if discard { "discarding smart previews" } else { "smart previews" };
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
s.preview_build = Some(state.clone());
let task = task_for(s, &state);
let st = state.clone();
std::thread::Builder::new()
.name("lc-smart-previews".into())
.spawn(move || {
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some((&st, &task))) {
log::warn!("{C}: {e}");
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
}
@@ -236,7 +294,54 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(state.json());
}
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
Ok(
json!({"built": r.built, "repaired": r.repaired, "refreshed": r.refreshed, "staleKept": r.stale_kept, "removed": r.removed, "failed": r.failed}),
)
}
/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when
/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker,
/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the
/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG,
/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose
/// original can't be read (offline) is kept as it is, still marked stale in its header: Build
/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none
/// is built, and a missing folder is never created. Returns the thread, if started.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::thread::JoinHandle<()>> {
let dir = s.media.smart_dir.clone()?;
if !dir.is_dir() || crate::smart::look_checked(&dir) {
return None;
}
let max_edge = crate::media::SourceLevel::Preview.max_edge();
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect();
let jobs: Vec<SmartJob> =
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
std::thread::Builder::new()
.name("lc-smart-look".into())
.spawn(move || refresh_stale(&dir, jobs))
.map_err(|e| log::warn!("smart previews: could not start the look refresh: {e}"))
.ok()
}
/// The body of [`refresh_stale_smart_previews`]: rebuild the stale proxies among `jobs`.
#[cfg(not(target_arch = "wasm32"))]
fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
let stale: Vec<SmartJob> = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect();
if !stale.is_empty() {
// `custom`: the folder is there already, and must not be recreated if it went away since
match smart_run(dir, true, false, stale, None) {
Ok(n) => log::info!("smart previews: {} refreshed to the current camera look, {} kept (original offline)", n.refreshed, n.stale_kept),
Err(e) => {
// not marked: the next opening tries again
log::warn!("smart previews: look refresh: {e}");
return;
}
}
}
if let Err(e) = crate::smart::mark_look_checked(dir) {
log::warn!("smart previews: {e}");
}
}
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
+14 -3
View File
@@ -43,7 +43,9 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
if let Some(rules) = &a.smart {
v["smart"] = json!(true);
v["rules"] = serde_json::to_value(rules).unwrap_or_default();
v["rulesText"] = json!(rules.describe());
v["rulesText"] = json!(rules.describe_with(cat));
// rules that no longer check (an album they test was deleted…), as `rule 1: …`
v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::<Vec<_>>());
}
v
}
@@ -149,9 +151,18 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(v)
}),
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| {
let id = photo_arg(s, p, "develop.get")?;
Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null))
Ok(s.develop_of(id)
.map(|d| {
let mut v = d.to_json();
// saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated
if let Some(o) = v.as_object_mut() {
o.insert("process".into(), json!(d.process));
}
v
})
.unwrap_or(Value::Null))
}),
cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| {
let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default();
+61
View File
@@ -0,0 +1,61 @@
//! Synchronize Folder commands (see [`crate::sync`]).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
use crate::sync::{SyncChoice, scan, synchronize};
use crate::{Result, Session};
fn path(p: &Value, c: &str) -> Result<String> {
str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`"))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "folder.scanChanges",
"Scan Folder for Changes",
[],
None,
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
always,
|s, p| {
const C: &str = "folder.scanChanges";
let path = path(p, C)?;
let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?;
let v = serde_json::to_value(&changes).unwrap_or_default();
s.folder_changes = Some(changes);
Ok(v)
}
),
cmd!(
"folder.synchronize",
"Synchronize Folder",
[],
None,
"{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}",
always,
sync
),
]
}
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "folder.synchronize";
let path = path(p, C)?;
let d = SyncChoice::default();
let choice = SyncChoice {
import_new: bool_or(p, "importNew", d.import_new),
relink_moved: bool_or(p, "relinkMoved", d.relink_moved),
remove_missing: bool_or(p, "removeMissing", d.remove_missing),
read_metadata: bool_or(p, "readMetadata", d.read_metadata),
};
let changes = match s.take_folder_changes(&path) {
Some(c) => c,
// the person agreed to what a scan showed: never act on something else
None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))),
None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?,
};
let r = synchronize(s, changes, choice)?;
Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}))
}
+378
View File
@@ -0,0 +1,378 @@
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
use crate::{Session, media::RenderJob};
use lightcraft_pipeline::{OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct Options {
pub paper: String,
pub landscape: bool,
pub columns: usize,
pub rows: usize,
pub captions: bool,
}
impl Default for Options {
fn default() -> Self {
Self { paper: "a4".into(), landscape: false, columns: 3, rows: 4, captions: true }
}
}
impl Options {
pub fn parse(p: &Value) -> Result<Self, String> {
let mut settings = p.as_object().cloned().ok_or("params must be an object")?;
settings.remove("path");
settings.remove("ids");
settings.remove("id");
let o: Self = serde_json::from_value(Value::Object(settings)).map_err(|e| e.to_string())?;
o.layout()?;
Ok(o)
}
fn layout(&self) -> Result<Layout, String> {
if !(1..=8).contains(&self.columns) || !(1..=10).contains(&self.rows) {
return Err("columns must be 1–8 and rows 1–10".into());
}
let (mut w, mut h) = match self.paper.as_str() {
"a4" => (595.28, 841.89),
"letter" => (612.0, 792.0),
_ => return Err("paper must be a4 or letter".into()),
};
if self.landscape {
std::mem::swap(&mut w, &mut h);
}
// Fixed 150 dpi, bounded paper sizes and grid dimensions keep allocations predictable.
let width = (w * 150.0 / 72.0_f64).round() as usize;
let height = (h * 150.0 / 72.0_f64).round() as usize;
Ok(Layout { points: (w, h), width, height, cell_w: (width - 120) / self.columns, cell_h: (height - 120) / self.rows })
}
}
struct Layout {
points: (f64, f64),
width: usize,
height: usize,
cell_w: usize,
cell_h: usize,
}
pub struct Prepared {
options: Options,
items: Vec<(String, RenderJob)>,
}
pub struct Document {
pub bytes: Vec<u8>,
pub pages: usize,
pub photos: usize,
}
pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
let options = Options::parse(p)?;
if let Some(ids) = p.get("ids") {
let a = ids.as_array().ok_or("ids must be an array of photo ids")?;
if a.iter().any(|v| v.as_u64().is_none()) {
return Err("ids must contain only photo ids".into());
}
}
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
return Err("id must be a photo id".into());
}
let ids = session.targets(p);
if ids.is_empty() || ids.len() > 1000 {
return Err("select between 1 and 1000 photos".into());
}
let l = options.layout()?;
if ids.len().div_ceil(options.columns * options.rows) > 100 {
return Err("contact sheets are limited to 100 pages; increase the grid size or select fewer photos".into());
}
let mut items = Vec::with_capacity(ids.len());
for id in ids {
let name = session.catalog.photo(id).ok_or("no such photo")?.file_name.clone();
let job = session.export_job(id, l.cell_w - 16, l.cell_h - 48, OutputSpace::Srgb, OutputDepth::U8)?;
items.push((name, job));
}
Ok(Prepared { options, items })
}
impl Prepared {
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Cancellation produces no partial document; callers write only after this succeeds.
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Document, String> {
let l = self.options.layout()?;
let capacity = self.options.columns * self.options.rows;
let photos = self.items.len();
let pages = photos.div_ceil(capacity);
let mut pdf = Pdf::new(pages, l.points);
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
for (i, (name, job)) in self.items.into_iter().enumerate() {
if !progress(i, &name) {
return Err("Contact sheet cancelled".into());
}
let image = job.run().rendered?.image;
let slot = i % capacity;
let x = 60 + slot % self.options.columns * l.cell_w;
let y = 60 + slot / self.options.columns * l.cell_h;
// The renderer fits within the requested rectangle and applies crop/orientation.
if image.width > l.cell_w - 16 || image.height > l.cell_h - 48 {
return Err("render exceeded contact sheet cell".into());
}
blit(&mut page, &image, x + (l.cell_w - image.width) / 2, y + (l.cell_h - 40 - image.height) / 2)?;
if self.options.captions {
let mut caption = Rgba8::filled(l.cell_w - 16, 28, [255; 4]);
draw_watermark(
&mut caption,
&Watermark {
text: caption_text(&name, l.cell_w - 16),
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
},
);
blit(&mut page, &caption, x + 8, y + l.cell_h - 36)?;
}
if slot + 1 == capacity || i + 1 == photos {
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
}
pdf.page(i / capacity, &jpg, l.width, l.height);
page.data.fill([255; 4]);
}
}
if !progress(photos, "PDF") {
return Err("Contact sheet cancelled".into());
}
Ok(Document { bytes: pdf.finish(), pages, photos })
}
}
fn caption_text(name: &str, width: usize) -> String {
use unicode_segmentation::UnicodeSegmentation;
let clean: String = name.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let cells: Vec<_> = clean.graphemes(true).collect();
// A conservative one-em budget at 14 px also accommodates the optional CJK faces.
let limit = (width / 14).max(3);
if cells.len() <= limit {
return clean;
}
let left = limit / 2;
let right = limit - left - 1;
format!("{}…{}", cells.iter().take(left).copied().collect::<String>(), cells.iter().skip(cells.len() - right).copied().collect::<String>())
}
fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> {
if x.checked_add(src.width).is_none_or(|w| w > dst.width) || y.checked_add(src.height).is_none_or(|h| h > dst.height) {
return Err("contact sheet image outside page".into());
}
for row in 0..src.height {
let start = (y + row) * dst.width + x;
let a = dst.data.get_mut(start..start + src.width).ok_or("invalid page buffer")?;
let b = src.data.get(row * src.width..(row + 1) * src.width).ok_or("invalid render buffer")?;
for (dst, src) in a.iter_mut().zip(b) {
let alpha = u32::from(src[3]);
for channel in 0..3 {
dst[channel] = ((u32::from(src[channel]) * alpha + u32::from(dst[channel]) * (255 - alpha) + 127) / 255) as u8;
}
dst[3] = 255;
}
}
Ok(())
}
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
struct Pdf {
bytes: Vec<u8>,
offsets: Vec<usize>,
points: (f64, f64),
}
impl Pdf {
fn new(pages: usize, points: (f64, f64)) -> Self {
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
p
}
fn object(&mut self, id: usize, body: &[u8]) {
self.offsets.push(self.bytes.len());
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
self.bytes.extend_from_slice(body);
self.bytes.extend_from_slice(b"\nendobj\n");
}
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(b"\nendstream");
self.object(id, &body);
}
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
let id = 3 + index * 3;
let (w, h) = self.points;
self.object(
id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
id + 2,
id + 1
)
.as_bytes(),
);
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
self.stream(
id + 2,
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
jpg,
);
}
fn finish(mut self) -> Vec<u8> {
let start = self.bytes.len();
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
for offset in self.offsets.iter().skip(1) {
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
self.bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn validates_settings_and_selection() {
let mut s = Session::with_demo();
for p in [
json!({"columns": 0}),
json!({"rows": 11}),
json!({"paper": "poster"}),
json!({"landscape": "yes"}),
json!({"typo": 2}),
json!({"ids": ["bad"]}),
json!({"ids": []}),
] {
assert!(prepare(&mut s, &p).is_err(), "{p}");
}
let a = Options::default().layout().unwrap();
let b = Options { landscape: true, ..Default::default() }.layout().unwrap();
assert_eq!((a.width, a.height), (b.height, b.width));
let id = s.catalog.photos().next().unwrap().id.0;
assert!(prepare(&mut s, &json!({"ids": [id, u64::MAX]})).is_err());
assert!(prepare(&mut s, &json!({"ids": vec![id; 1001]})).is_err());
assert!(prepare(&mut s, &json!({"ids": vec![id; 101], "columns": 1, "rows": 1})).is_err());
}
#[test]
fn transparent_pixels_composite_over_white() {
let mut dst = Rgba8::filled(3, 1, [255; 4]);
let src = Rgba8 { width: 3, height: 1, data: vec![[0, 0, 0, 0], [255, 0, 0, 128], [10, 20, 30, 255]] };
blit(&mut dst, &src, 0, 0).unwrap();
assert_eq!(dst.data, vec![[255; 4], [255, 127, 127, 255], [10, 20, 30, 255]]);
assert!(blit(&mut dst, &src, usize::MAX, 0).is_err());
}
#[test]
fn unicode_captions_are_rasterized() {
assert_eq!(caption_text("very-long-éééé-filename.jpg", 140), "very-….jpg");
assert_eq!(caption_text("café\n.jpg", 300), "café .jpg");
let mut img = Rgba8::filled(360, 28, [255; 4]);
draw_watermark(
&mut img,
&Watermark {
text: "Été – Straße café.jpg".into(),
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
},
);
assert!(img.data.iter().filter(|p| p[0] < 128).count() > 100);
let mut ascii = Rgba8::filled(360, 28, [255; 4]);
draw_watermark(
&mut ascii,
&Watermark {
text: "Ete - Strasse cafe.jpg".into(),
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
},
);
assert_ne!(img, ascii);
}
#[test]
fn pagination_xref_and_cancellation() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
let p = json!({"ids": ids, "columns": 1, "rows": 2, "captions": false});
let mut seen = Vec::new();
let doc = prepare(&mut s, &p)
.unwrap()
.run(&mut |i, _| {
seen.push(i);
true
})
.unwrap();
assert_eq!((doc.pages, doc.photos), (2, 3));
assert_eq!(seen, vec![0, 1, 2, 3]);
let text = String::from_utf8_lossy(&doc.bytes);
assert!(text.contains("/Count 2 /Kids [3 0 R 6 0 R]"));
let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap();
assert!(doc.bytes[xref..].starts_with(b"xref\n0 9\n"));
let xref_text = std::str::from_utf8(&doc.bytes[xref..]).unwrap();
for (index, line) in xref_text.lines().skip(3).take(8).enumerate() {
let offset = line.split_whitespace().next().unwrap().parse::<usize>().unwrap();
assert!(doc.bytes[offset..].starts_with(format!("{} 0 obj\n", index + 1).as_bytes()));
}
assert!(prepare(&mut s, &p).unwrap().run(&mut |_, _| false).is_err());
}
fn first_page(bytes: &[u8]) -> Rgba8 {
let start = bytes.windows(2).position(|v| v == [0xff, 0xd8]).unwrap();
let end = bytes[start..].windows(2).position(|v| v == [0xff, 0xd9]).unwrap() + start + 2;
lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()
}
#[test]
fn sheet_uses_current_edits_and_captions() {
let mut s = Session::with_demo();
let p = json!({"columns": 1, "rows": 2, "captions": false});
let plain = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
let bright = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
let sum = |img: &Rgba8| img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum::<u64>();
assert!(sum(&bright) > sum(&plain), "sheet ignored develop settings");
let captions = first_page(&prepare(&mut s, &json!({"columns": 1, "rows": 2})).unwrap().run(&mut |_, _| true).unwrap().bytes);
assert!(sum(&captions) < sum(&bright), "captions absent from PDF pixels");
assert_eq!((plain.width, plain.height), (1240, 1754));
}
}
+181 -36
View File
@@ -18,6 +18,43 @@ use serde_json::{Map, Value, json};
/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
pub type Props = BTreeMap<String, Vec<String>>;
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its
/// as-shot look (rendered files, and raws whose readers have no illuminant,
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
Any,
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
/// relative scale.
Rendered,
/// A raw developed with an absolute white balance (DNG and other files with a measured
/// illuminant): Kelvin as written.
RawAbsolute,
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
RawRelative,
}
impl Target {
/// The target for a file of this media kind and format (its extension or Lightroom's
/// `fileFormat` name); `preview_only` for a raw that can't be decoded yet.
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
Target::Rendered
} else if lightcraft_catalog::relative_wb_format(format) {
Target::RawRelative
} else {
Target::RawAbsolute
}
}
/// The target for a photo in the catalog.
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
}
}
/// Band names as they appear in `crs:` field names, in our mixer order.
const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
@@ -69,6 +106,8 @@ pub fn has_adjustments(props: &Props) -> bool {
thread_local! {
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
/// Adjustments [`to_partial`] read but deliberately left out, for the report.
static SKIPPED: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
}
fn note(k: &str) {
@@ -79,6 +118,14 @@ fn note(k: &str) {
});
}
fn skipped(what: &str) {
SKIPPED.with(|r| {
if let Some(s) = r.borrow_mut().as_mut() {
s.push(what.to_string());
}
});
}
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
note(k);
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
@@ -138,10 +185,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
/// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object.
///
/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the
/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever
/// is present, absolute first.
pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
/// `target` decides how the white balance is read (see [`Target`]).
pub fn to_partial(props: &Props, target: Target) -> Value {
let mut out = Value::Object(Map::new());
let o = &mut out;
let n = |o: &mut Value, crs: &str, path: &str| {
@@ -200,10 +245,24 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
let rel = num(props, "crs:IncrementalTemperature")
.or(num(props, "crs:IncrementalTint").map(|_| 0.0))
.map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint")));
let wb = match raw {
Some(true) => abs,
Some(false) => rel.or(abs),
None => abs.or(rel),
// A Kelvin value for something we develop relative to its as-shot look: the other app's
// as-shot white (`AsShotTemperature` / `AsShotTint`, written next to the edit) is the
// reference, so the edit is the same mired shift from ours (issue #510).
let shifted = || {
let (t, tint) = abs?;
let as_shot = num(props, "crs:AsShotTemperature").filter(|k| *k > 0.0)?;
if t <= 0.0 {
return None;
}
let r = (1e6 / as_shot - 1e6 / t) / 0.8;
let tint = tint.map(|v| v - num(props, "crs:AsShotTint").unwrap_or(0.0));
Some((rel_to_kelvin(r), tint))
};
let wb = match target {
Target::RawAbsolute => abs,
Target::Any => abs.or(rel),
Target::Rendered => rel.or_else(shifted).or(abs),
Target::RawRelative => rel.or_else(shifted),
};
match (mode, wb) {
(Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)),
@@ -214,6 +273,13 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
put(o, "wb.tint", json!(tint));
}
}
// a custom Kelvin with nothing to shift it from: read on the relative scale it would be a
// large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so
// the photo stays As Shot (ΔE 12 there) and the report says so
(_, None) if target == Target::RawRelative && abs.is_some() => {
put(o, "wb.mode", json!("asShot"));
skipped("Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)");
}
(Some(m), None) => put(o, "wb.mode", json!(m)),
(None, None) => {}
}
@@ -244,13 +310,19 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
put(o, "curve.master", c);
}
}
// Lightroom Classic reads the red / green / blue curves only together with the master 2012
// curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged
// Lightroom Classic reads the red / green / blue curves only when the packet holds the master
// 2012 curve and all three channel curves (as its own packets always do): a packet missing any
// of them imports without channel curves (master + red, master + red + green, or red / green /
// blue without the master all leave the photo's channels unchanged there)
if let Some(c) = curve(props, "crs:ToneCurvePV2012") {
put(o, "curve.master", c);
for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] {
if let Some(c) = curve(props, &format!("crs:{crs}")) {
put(o, &format!("curve.{ch}"), c);
let channels = [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
.map(|(crs, ch)| (curve(props, &format!("crs:{crs}")), ch));
if channels.iter().all(|(c, _)| c.is_some()) {
for (c, ch) in channels {
if let Some(c) = c {
put(o, &format!("curve.{ch}"), c);
}
}
}
}
@@ -375,11 +447,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
/// With `values` (the packet's structured properties), local corrections become masks too
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
/// can't be carried over are reported as `Mask: <kind>`.
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec<String>) {
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
let mut out = to_partial(props, raw);
SKIPPED.with(|r| *r.borrow_mut() = Some(Vec::new()));
let mut out = to_partial(props, target);
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
let mut mask_skips = Vec::new();
let mut mask_skips = SKIPPED.with(|r| r.borrow_mut().take()).unwrap_or_default();
if let Some(values) = values {
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
if !masks.is_empty() {
@@ -387,7 +460,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
}
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}")));
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
@@ -421,7 +494,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
let d = lightcraft_meta::parse_xmp(xmp).ok()?;
let props = &d.properties;
let settings = to_partial(props, None);
let settings = to_partial(props, Target::Any);
if settings.as_object().is_none_or(Map::is_empty) {
return None;
}
@@ -474,7 +547,7 @@ mod tests {
fn maps_common_fields() {
let p = props(SIDECAR);
assert!(has_adjustments(&p));
let partial = to_partial(&p, Some(true));
let partial = to_partial(&p, Target::RawAbsolute);
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
assert_eq!(s.wb.mode, WbMode::Custom);
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
@@ -516,7 +589,7 @@ mod tests {
fn only_present_fields_are_in_the_partial() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:c="http://ns.adobe.com/camera-raw-settings/1.0/" c:Vibrance="+30" c:GrainAmount="10"/></rdf:RDF>"#;
let partial = to_partial(&props(x), None);
let partial = to_partial(&props(x), Target::Any);
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
}
@@ -531,12 +604,12 @@ mod tests {
<crs:Temperature>4800</crs:Temperature>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let p = props(x);
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0);
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0);
assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw);
assert_eq!(rendered.bw_mix.blue, -35.0);
assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0);
assert_eq!(rendered.wb.tint, -6.0);
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0);
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0);
assert_eq!(raw.wb.temp, 4800.0);
}
@@ -545,13 +618,72 @@ mod tests {
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Daylight" crs:Temperature="5500" crs:Tint="10" crs:AlreadyApplied="True"/>"#;
let p = props(x);
assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}}));
assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}}));
assert!(!has_adjustments(&p));
let bookkeeping = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="1" crs:HasSettings="True"/>"#;
assert!(!has_adjustments(&props(bookkeeping)));
}
#[test]
fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() {
// Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white
// of 3650 K / −2 (written for this test from the numbers in the issue).
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:AsShotTemperature="3650" crs:AsShotTint="-2"/>"#;
let p = props(x);
let rel = to_partial(&p, Target::RawRelative);
// 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale
let t = rel["wb"]["temp"].as_f64().unwrap();
assert!((t - 6275.0).abs() < 5.0, "{t}");
assert_eq!(rel["wb"]["tint"], -3.0);
assert_eq!(rel["wb"]["mode"], "custom");
// a rendered file without `Incremental*` fields shifts the same way; a raw with a measured
// illuminant (and a preset) keeps the Kelvin value
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0);
// the as-shot fields are a reference, not an adjustment: nothing to report
assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty());
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, ""));
assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0);
}
#[test]
fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() {
// The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos:
// 3578 K read on the relative scale would be a large blue shift (issue #510)
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:Exposure2012="+0.3"/>"#;
let p = props(x);
let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5);
assert_eq!(partial["wb"], json!({"mode": "asShot"}));
assert_eq!(partial["light"]["exposure"], 0.3);
assert_eq!(unmapped.len(), 1, "{unmapped:?}");
assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}");
// a raw with a measured illuminant and a rendered file read it as before, and report nothing
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0);
assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty());
// an unusable reference (zero) counts as none
let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#));
assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"}));
}
#[test]
fn target_follows_the_file() {
use lightcraft_catalog::MediaKind;
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative);
// Lightroom's `fileFormat` name for proprietary raws
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative);
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
// a raw shown from its embedded preview develops like a rendered file
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered);
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered);
}
#[test]
fn xmp_preset_with_name_and_group() {
// Written for this test: a minimal preset-shaped packet with localized name/group.
@@ -584,25 +716,38 @@ mod tests {
}
#[test]
fn channel_curves_need_the_master_curve() {
// Lightroom ignores a red / green / blue curve without `ToneCurvePV2012`
let packet = |master: &str| {
fn channel_curves_need_the_master_and_all_channel_curves() {
// Lightroom applies red / green / blue curves only when the master and all three are present
let seq = |name: &str, pts: &str| format!("<crs:{name}><rdf:Seq>{pts}</rdf:Seq></crs:{name}>");
let identity = "<rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li>";
let bent = "<rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li>";
let packet = |curves: &[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:ToneCurveName2012="Custom">{master}
<crs:ToneCurvePV2012Green><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 40</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012Green>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ToneCurveName2012="Custom">{}
</rdf:Description></rdf:RDF></x:xmpmeta>"#,
curves.concat()
)
};
let alone = to_partial(&props(&packet("")), Some(true));
let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute);
let (master, red, green, blue) = (
seq("ToneCurvePV2012", identity),
seq("ToneCurvePV2012Red", identity),
seq("ToneCurvePV2012Green", bent),
seq("ToneCurvePV2012Blue", identity),
);
// a channel curve alone, or with the master but without its two siblings: ignored
let alone = partial(std::slice::from_ref(&green));
assert!(alone.pointer("/curve/green").is_none(), "{alone}");
let master = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
let with = to_partial(&props(&packet(master)), Some(true));
assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}");
let master_and_green = partial(&[master.clone(), green.clone()]);
assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}");
// all four: read
let all = partial(&[master, red, green, blue]);
assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}");
}
fn unmapped(x: &str) -> Vec<String> {
to_partial_report(&props(x), None, None, 1.5).1
to_partial_report(&props(x), None, Target::Any, 1.5).1
}
#[test]
@@ -622,8 +767,8 @@ mod tests {
let p = props(&preset_packet("", ""));
let mut d = DevelopSettings::default();
d.optics.lens_profile = true;
assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile);
assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile);
let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#));
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile);
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile);
}
}
+1 -1
View File
@@ -298,7 +298,7 @@ mod tests {
fn settings() -> (DevelopSettings, Vec<String>) {
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
+463 -17
View File
@@ -101,14 +101,15 @@ pub fn load(s: &mut Session) {
/// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI
/// VM). Each scene at each size is therefore generated once per process and shared:
///
/// - concurrent requests wait for one generation (Before and After at 1:1 both load the
/// original; sessions side by side, e.g. parallel tests, open the same photo);
/// - one generation per scene and size, which concurrent requests wait for (Before and After at
/// 1:1 both load the original; sessions side by side, e.g. parallel tests, open the same photo;
/// how it renders: [`generate`]);
/// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same
/// allocations the sessions' source caches hold, so they cost extra memory only once no session
/// holds them any more.
///
/// Only demo photos use this; real files are decoded by their loader as before.
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc<lightcraft_raster::Rgb32f> {
pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Result<Arc<lightcraft_raster::Rgb32f>, String> {
let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge };
let slot = {
let mut c = scene_cache();
@@ -120,18 +121,152 @@ pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) ->
e.slot.clone()
}
None => {
let slot = Arc::new(std::sync::OnceLock::new());
let slot = Arc::new(SceneSlot::default());
c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick });
slot
}
}
};
// (no lock held while generating: other scenes and sizes are served meanwhile)
let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone();
let pixels = slot.get_or_generate(|how| match how {
Render::Parallel => scene.render_fit(max_edge),
Render::Serial => scene.render_fit_serial(max_edge),
});
trim_scene_cache(&mut scene_cache(), scene_cache_limit());
pixels
}
/// How a generation renders (see [`generate`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Render {
/// On rayon threads that do nothing else.
Parallel,
/// On the calling thread alone, without rayon.
Serial,
}
const FAILED: &str = "the demo scene could not be generated";
/// Render a scene for [`SceneSlot::get_or_generate`], so that it never needs a thread that waits
/// for a generation. A rayon worker renders on its own thread alone: rendering in parallel, it
/// would run queued jobs while it waits for rows other workers took, and one of them could ask for
/// this very scene and wait for the generation further down its own stack (the faces scan's pool
/// threads hung that way). That also keeps the work within the caller's pool and its limits (the
/// scan's pace, `LIGHTCRAFT_FACE_THREADS`). Any other caller (the render workers, which are plain
/// threads) renders in parallel on [`render_pool`], whose threads only ever render, and blocks
/// without running other work meanwhile; where that pool can't be started it renders alone too.
fn generate(render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> lightcraft_raster::Rgb32f {
if rayon::current_thread_index().is_some() {
return render(Render::Serial);
}
match render_pool() {
Some(pool) => pool.install(|| render(Render::Parallel)),
None => render(Render::Serial),
}
}
/// The threads non-rayon callers' generations render on: rayon's default count (`RAYON_NUM_THREADS`,
/// else the available parallelism), like the global pool they used before, without touching that
/// pool (asking it for its size starts it, which panics where its threads can't start); `None`
/// where this pool's threads can't start.
fn render_pool() -> Option<&'static rayon::ThreadPool> {
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
POOL.get_or_init(|| rayon::ThreadPoolBuilder::new().thread_name(|i| format!("lc-demo-scene-{i}")).build().ok()).as_ref()
}
/// One scene at one size: generated once, then shared.
#[derive(Default)]
struct SceneSlot {
state: std::sync::Mutex<SlotState>,
/// Signalled when a generation ends.
ended: std::sync::Condvar,
}
#[derive(Default)]
struct SlotState {
generation: Generation,
/// Generations started so far; the one running or last ended is number `started`.
started: u64,
/// Callers waiting for the generation running (for the tests).
waiting: usize,
}
#[derive(Default)]
enum Generation {
#[default]
Empty,
Running,
Ready(Arc<lightcraft_raster::Rgb32f>),
/// The last generation failed (its render panicked); the next request tries again.
Failed,
}
impl SlotState {
/// Start a generation; its number.
fn start(&mut self) -> u64 {
self.started += 1;
self.generation = Generation::Running;
self.started
}
/// What came of generation `n`, once it has ended: its pixels, or an error when it failed
/// (even if a retry has started or finished since: callers get the outcome they waited for).
fn outcome(&self, n: u64) -> Option<Result<Arc<lightcraft_raster::Rgb32f>, String>> {
match &self.generation {
Generation::Running if self.started == n => None,
// pixels are kept for good once made, so only generation `n` itself can have made them
Generation::Ready(p) if self.started == n => Some(Ok(p.clone())),
_ => Some(Err(FAILED.into())),
}
}
}
impl SceneSlot {
fn lock(&self) -> std::sync::MutexGuard<'_, SlotState> {
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn ready(&self) -> Option<Arc<lightcraft_raster::Rgb32f>> {
match &self.lock().generation {
Generation::Ready(p) => Some(p.clone()),
_ => None,
}
}
/// The pixels `render` makes ([`generate`]), generated once: the first caller renders, with no
/// lock held, and later ones wait for its outcome. A render that panics is an error for the
/// callers of its generation, and the next request tries again.
fn get_or_generate(&self, render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> Result<Arc<lightcraft_raster::Rgb32f>, String> {
let mut g = self.lock();
let n = match &g.generation {
Generation::Ready(p) => return Ok(p.clone()),
Generation::Running => {
let n = g.started;
g.waiting += 1;
let outcome = loop {
if let Some(o) = g.outcome(n) {
break o;
}
g = self.ended.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
};
g.waiting -= 1;
return outcome;
}
Generation::Empty | Generation::Failed => g.start(),
};
drop(g);
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| generate(render)));
let mut g = self.lock();
g.generation = match made {
Ok(pixels) => Generation::Ready(Arc::new(pixels)),
Err(_) => Generation::Failed,
};
let outcome = g.outcome(n).unwrap_or_else(|| Err(FAILED.into()));
drop(g);
self.ended.notify_all();
outcome
}
}
/// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough
/// for one 24 MP original and the previews around it).
fn scene_cache_limit() -> usize {
@@ -147,11 +282,9 @@ struct SceneKey {
max_edge: usize,
}
type SceneSlot = Arc<std::sync::OnceLock<Arc<lightcraft_raster::Rgb32f>>>;
struct SceneEntry {
key: SceneKey,
slot: SceneSlot,
slot: Arc<SceneSlot>,
used: u64,
}
@@ -167,7 +300,7 @@ fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> {
}
fn scene_bytes(slot: &SceneSlot) -> usize {
slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
slot.ready().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>()))
}
/// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still
@@ -178,7 +311,7 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) {
if total <= limit {
return;
}
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.ready().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else {
return;
};
c.entries.swap_remove(i);
@@ -196,12 +329,12 @@ mod tests {
#[test]
fn a_scene_is_generated_once_per_size_and_shared() {
let s = small_scene();
let a = scene_pixels(&s, 48);
let b = scene_pixels(&s, 48);
let a = scene_pixels(&s, 48).unwrap();
let b = scene_pixels(&s, 48).unwrap();
assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation");
assert_eq!(a.width.max(a.height), 48);
assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly");
let c = scene_pixels(&s, 40);
let c = scene_pixels(&s, 40).unwrap();
assert_eq!(c.width.max(c.height), 40, "another size is its own generation");
}
@@ -210,19 +343,332 @@ mod tests {
let s = small_scene();
let edge = 37; // a size no other test asks for
let got: Vec<_> = std::thread::scope(|sc| {
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect();
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
}
/// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it.
fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked")
}
/// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation.
fn until_waiting(slot: &SceneSlot, n: usize) {
let started = std::time::Instant::now();
while slot.lock().waiting < n {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait");
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
fn pixels(n: usize) -> lightcraft_raster::Rgb32f {
lightcraft_raster::Rgb32f::new(n, 1)
}
/// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker
/// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the
/// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests
/// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one
/// of them must come back, at least four of them must have overlapped the generation, and the test waits with a
/// timeout, so a deadlock fails it instead of hanging.
#[test]
fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() {
let scene = Arc::new(small_scene());
let edge = 601; // a size no other test asks for
const ASKED: usize = 24;
let (overlap, sizes) = within(120, move || {
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (done_tx, done_rx) = std::sync::mpsc::channel();
for _ in 0..ASKED {
let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone());
issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
pool.spawn(move || {
let got = scene_pixels(&scene, edge).unwrap();
// how many requests had been made when this one came back
let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len()));
});
std::thread::sleep(std::time::Duration::from_millis(1));
}
let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect();
(replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::<Vec<_>>())
});
assert_eq!(sizes.len(), ASKED, "every request came back");
assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0));
assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)");
}
/// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered
/// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original).
#[test]
fn a_scene_is_rendered_once_however_many_ask() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (renders, peak) = within(60, || {
let slot = Arc::new(SceneSlot::default());
let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let render = {
let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone());
move |how: Render| {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
renders.fetch_add(1, Ordering::SeqCst);
// work like the real render's: in parallel, the rendering thread waits for rows other threads took
use rayon::prelude::*;
let sum: u64 = match how {
Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(),
Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(),
};
std::thread::sleep(std::time::Duration::from_millis(100));
running.fetch_sub(1, Ordering::SeqCst);
pixels(sum as usize % 5 + 1)
}
};
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
for _ in 0..16 {
let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone());
pool.spawn(move || {
let _ = tx.send(slot.get_or_generate(render).unwrap());
});
}
for _ in 0..4 {
let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone());
std::thread::spawn(move || {
let _ = tx.send(slot.get_or_generate(render).unwrap());
});
}
let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect();
assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels");
(renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst))
});
assert_eq!((renders, peak), (1, 1));
}
/// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool
/// and that pool's limits; other threads render in parallel on the scene pool.
#[test]
fn a_rayon_worker_renders_alone_on_its_own_thread() {
let ((how, caller, renderer), (other_how, other_name)) = within(30, || {
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
let slot = SceneSlot::default();
let mut seen = None;
pool.install(|| {
let caller = std::thread::current().id();
slot.get_or_generate(|how| {
seen = Some((how, caller, std::thread::current().id()));
pixels(1)
})
.unwrap();
});
let other = SceneSlot::default();
let mut other_seen = None;
other
.get_or_generate(|how| {
other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string()));
pixels(1)
})
.unwrap();
(seen.unwrap(), other_seen.unwrap())
});
assert_eq!((how, renderer), (Render::Serial, caller));
assert_eq!(other_how, Render::Parallel);
assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}");
}
/// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs
/// minutes of CPU time on a small machine).
#[test]
fn callers_wait_for_the_generation_running() {
let (a, b, renders) = within(30, || {
let slot = Arc::new(SceneSlot::default());
let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (started_tx, started) = std::sync::mpsc::channel::<()>();
let (go, go_rx) = std::sync::mpsc::channel::<()>();
let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx)));
let ask = |slot: &Arc<SceneSlot>| {
let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone());
std::thread::spawn(move || {
slot.get_or_generate(move |_| {
renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _ = started_tx.lock().unwrap().send(());
let _ = go_rx.lock().unwrap().recv();
pixels(1)
})
})
};
let first = ask(&slot);
started.recv().unwrap();
let second = ask(&slot);
until_waiting(&slot, 1);
go.send(()).unwrap();
let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap());
(a, b, renders.load(std::sync::atomic::Ordering::SeqCst))
});
assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared");
assert_eq!(renders, 1);
}
/// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request
/// generates the scene again.
#[test]
fn a_failed_generation_is_an_error_and_the_next_request_tries_again() {
let (first, waiters, again) = within(30, || {
let slot = Arc::new(SceneSlot::default());
let (started_tx, started) = std::sync::mpsc::channel::<()>();
let (go, go_rx) = std::sync::mpsc::channel::<()>();
let failing = {
let slot = slot.clone();
std::thread::spawn(move || {
slot.get_or_generate(move |_| {
let _ = started_tx.send(());
let _ = go_rx.recv();
panic!("render failed");
})
})
};
started.recv().unwrap();
let waiters: Vec<_> = (0..2)
.map(|_| {
let slot = slot.clone();
std::thread::spawn(move || slot.get_or_generate(|_| pixels(2)))
})
.collect();
until_waiting(&slot, 2);
drop(go);
let first = failing.join().unwrap();
let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect();
(first, waiters, slot.get_or_generate(|_| pixels(3)))
});
assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error");
assert_eq!(again.unwrap().width, 3);
}
/// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they
/// look: a failed generation followed by a retry is still an error for them.
#[test]
fn a_retry_never_changes_the_outcome_waited_for() {
let mut st = SlotState::default();
let n = st.start();
assert!(st.outcome(n).is_none(), "running");
st.generation = Generation::Failed;
let m = st.start();
assert!(matches!(st.outcome(n), Some(Err(_))));
assert!(st.outcome(m).is_none());
st.generation = Generation::Ready(Arc::new(pixels(1)));
assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded");
assert!(matches!(st.outcome(m), Some(Ok(_))));
// and with threads: a retry racing the waiters of a failed generation never hands them its result
let wrong = within(60, || {
let mut wrong = 0;
for _ in 0..20 {
let slot = Arc::new(SceneSlot::default());
let (started_tx, started) = std::sync::mpsc::channel::<()>();
let (go, go_rx) = std::sync::mpsc::channel::<()>();
let failing = {
let slot = slot.clone();
std::thread::spawn(move || {
slot.get_or_generate(move |_| {
let _ = started_tx.send(());
let _ = go_rx.recv();
panic!("render failed");
})
})
};
started.recv().unwrap();
let waiters: Vec<_> = (0..3)
.map(|_| {
let slot = slot.clone();
std::thread::spawn(move || slot.get_or_generate(|_| pixels(2)))
})
.collect();
until_waiting(&slot, 3);
let retry = {
let slot = slot.clone();
std::thread::spawn(move || {
loop {
if matches!(slot.lock().generation, Generation::Failed) {
return slot.get_or_generate(|_| pixels(4));
}
std::hint::spin_loop();
}
})
};
drop(go);
let _ = failing.join().unwrap();
wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count();
assert!(retry.join().unwrap().is_ok());
}
wrong
});
assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels");
}
/// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it
/// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's.
#[test]
fn scenes_are_generated_when_the_global_pool_is_broken() {
use std::io::Read;
use std::process::Stdio;
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"])
.env("LC_DEMO_BROKEN_GLOBAL_POOL", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// Drain both pipes on threads so a chatty child can't block on a full pipe.
let drain = |mut r: Box<dyn Read + Send>| {
std::thread::spawn(move || {
let mut s = String::new();
let _ = r.read_to_string(&mut s);
s
})
};
let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap())));
// A deadlocked child fails the test instead of hanging CI.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("the child process did not finish within 120 s");
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap());
assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}");
}
#[test]
#[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"]
fn with_a_broken_global_pool() {
if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() {
return;
}
let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global();
assert!(broken.is_err());
assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken");
let s = small_scene();
let got = scene_pixels(&s, 33).unwrap();
assert_eq!(got.data, s.render_fit_serial(33).data);
}
#[test]
fn the_cache_keeps_the_most_recent_scenes_within_its_limit() {
let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge };
let entry = |max_edge, used, px: Option<usize>| {
let slot: SceneSlot = Arc::new(std::sync::OnceLock::new());
let slot = Arc::new(SceneSlot::default());
if let Some(n) = px {
let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1)));
}
SceneEntry { key: key(max_edge), slot, used }
};
+28
View File
@@ -890,6 +890,8 @@ pub(crate) struct State {
pub generation: u64,
/// Photos made since the session started.
made: u64,
/// AI Denoise's row in the activity stack, while a photo is being made or waits its turn.
task: Option<crate::activity::TaskGuard>,
}
impl Default for State {
@@ -909,6 +911,7 @@ impl Default for State {
failed: HashMap::new(),
generation: 0,
made: 0,
task: None,
}
}
}
@@ -1354,6 +1357,31 @@ impl Session {
self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some())
}
/// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or
/// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is
/// made follows the photos' Denoise amounts and Settings ▸ AI Denoise.
pub(crate) fn denoise_track(&mut self) {
if !self.denoise_busy() {
self.denoise.task = None;
return;
}
let task = self.denoise.task.get_or_insert_with(|| {
let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No);
t.set_unit(crate::activity::Unit::Percent);
t
});
match &self.denoise.running {
Some(r) => {
task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64);
task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str()));
}
None => {
task.progress(0, 0);
task.detail("");
}
}
}
/// Seconds the running job has been going, and its progress.
pub(crate) fn denoise_running_json(&self) -> Value {
match &self.denoise.running {
+176
View File
@@ -0,0 +1,176 @@
//! The monitor profile (`app.displayProfile`): previews shown on a wide-gamut or calibrated
//! display through its ICC profile.
//!
//! The frontend holds the profile in use (Settings ▸ Display, the UI command `app.displayProfile`).
//! With a profile, the loupe renders into the display's own primaries
//! ([`RenderJob::with_display`](crate::media::RenderJob::with_display)) and everything else made
//! for sRGB (thumbnails, cached previews, embedded camera JPEGs) is converted to it
//! ([`present`]). Histograms and the preview caches stay sRGB, and exports never use it.
pub use lightcraft_codecs::display::DisplayKind;
use lightcraft_codecs::display::{DisplayProfile, MAX_PROFILE_BYTES};
use lightcraft_pipeline::DisplaySpace;
use lightcraft_raster::{Histogram, Rgba8};
use serde_json::{Value, json};
use std::sync::Arc;
use crate::media::RenderResult;
/// A loaded display profile: its transforms and the pipeline target.
#[derive(Debug)]
pub struct Display {
/// The file it came from.
pub path: String,
pub profile: DisplayProfile,
pub space: DisplaySpace,
}
impl Display {
/// Load the ICC profile at `path`.
pub fn load(path: &str) -> Result<Display, String> {
let path = path.trim();
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if meta.len() > MAX_PROFILE_BYTES as u64 {
return Err(format!("{path}: too large for an ICC profile"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
Display::from_bytes(path, &bytes)
}
pub fn from_bytes(path: &str, bytes: &[u8]) -> Result<Display, String> {
let profile = DisplayProfile::from_icc(bytes).map_err(|e| format!("{path}: {e}"))?;
let space = DisplaySpace::new(profile.to_rec2020, profile.id).ok_or_else(|| format!("{path}: the profile's primaries are degenerate"))?;
Ok(Display { path: path.to_string(), profile, space })
}
pub fn id(&self) -> u64 {
self.profile.id
}
/// Load the profile at `path` (`""`: none, the display is treated as sRGB).
pub fn load_opt(path: &str) -> Result<Option<Arc<Display>>, String> {
if path.trim().is_empty() { Ok(None) } else { Display::load(path).map(|d| Some(Arc::new(d))) }
}
/// For `app.displayProfile` and Settings: path, description, kind, primaries (xy).
pub fn describe(&self) -> Value {
let to_xyz = lightcraft_color::REC2020.to_xyz().mul(&self.profile.to_rec2020).0;
let xy = |c: usize| {
let (x, y, z) = (to_xyz[0][c], to_xyz[1][c], to_xyz[2][c]);
let s = x + y + z;
if s.abs() < 1e-12 { json!([0.0, 0.0]) } else { json!([round4(x / s), round4(y / s)]) }
};
json!({
"path": self.path,
"description": self.profile.description,
"kind": self.profile.kind.label(),
"primaries": {"red": xy(0), "green": xy(1), "blue": xy(2)},
})
}
}
fn round4(v: f64) -> f64 {
(v * 1e4).round() / 1e4
}
/// Make `r` ready for `display`: an sRGB image (thumbnail, cached preview, camera JPEG) is
/// converted to the display's values. A render made for the display (see
/// [`RenderJob::with_display`](crate::media::RenderJob::with_display)) is already.
pub fn present(r: &mut RenderResult, display: &Display) {
if r.display.is_some() {
return;
}
if let Ok(rendered) = &mut r.rendered {
match display.profile.from_srgb(&mut rendered.image) {
Ok(()) => r.display = Some(display.id()),
Err(e) => log::warn!("display profile: {e}"),
}
}
}
/// A render into `display`'s primaries (sRGB curve) → in place, the display's device values; and
/// its sRGB histogram, plus (`full_srgb`: for the cached view preview) its whole sRGB version.
/// The histogram samples the same pixels [`Histogram::of_srgb8`] would, so only those are
/// converted when the sRGB image itself isn't needed (slider drags).
pub(crate) fn finish_render(image: &mut Rgba8, display: &Display, full_srgb: bool) -> (Option<Rgba8>, Option<Histogram>) {
let warn = |e: String| log::warn!("display profile: {e}");
let (srgb, histogram) = if full_srgb {
let srgb = display.profile.to_srgb(image).map_err(warn).ok();
let h = srgb.as_ref().map(Histogram::of_srgb8);
(srgb, h)
} else {
let sample = histogram_sample(image);
(None, display.profile.to_srgb(&sample).map_err(warn).ok().map(|s| Histogram::of_srgb8(&s)))
};
if let Err(e) = display.profile.correct(image) {
warn(e);
}
(srgb, histogram)
}
/// The pixels [`Histogram::of_srgb8`] reads (at most ~1 MP, a regular grid), as an image of
/// their own (which it then reads whole).
fn histogram_sample(img: &Rgba8) -> Rgba8 {
let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1);
if step == 1 {
return img.clone();
}
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
let mut data = Vec::with_capacity(w * h);
for y in (0..img.height).step_by(step) {
for x in (0..img.width).step_by(step) {
data.push(img.get(x, y));
}
}
Rgba8 { width: w, height: h, data }
}
#[cfg(test)]
mod tests {
use super::*;
fn p3() -> Display {
Display::from_bytes("p3.icc", &lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap()
}
fn picture(w: usize, h: usize) -> Rgba8 {
Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i * 7 % 256) as u8, (i * 13 % 256) as u8, (i * 29 % 256) as u8, 255]).collect() }
}
/// The sampled histogram of a drag render is exactly the histogram of the whole sRGB image.
#[test]
fn sampled_histogram_matches_the_full_one() {
let d = p3();
for (w, h) in [(40, 30), (1500, 1100), (2561, 1709)] {
let img = picture(w, h);
let (mut a, mut b) = (img.clone(), img.clone());
let (srgb, full) = finish_render(&mut a, &d, true);
let (none, sampled) = finish_render(&mut b, &d, false);
assert!(srgb.is_some() && none.is_none());
assert_eq!(full.unwrap(), sampled.unwrap(), "{w}x{h}");
assert_eq!(a, b);
}
}
#[test]
fn load_reports_and_rejects() {
let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("p3.icc");
std::fs::write(&good, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap();
let bad = dir.join("bad.icc");
std::fs::write(&bad, b"not a profile").unwrap();
let d = Display::load_opt(&good.to_string_lossy()).unwrap().unwrap();
let r = d.describe();
assert_eq!(r["kind"], "matrix");
assert_eq!(r["description"], "Display P3");
let red = r["primaries"]["red"].as_array().unwrap();
assert!((red[0].as_f64().unwrap() - 0.68).abs() < 2e-3 && (red[1].as_f64().unwrap() - 0.32).abs() < 2e-3, "{r}");
assert!(Display::load_opt(" ").unwrap().is_none());
let e = Display::load(&bad.to_string_lossy()).unwrap_err();
assert!(e.contains("not a valid ICC profile"), "{e}");
assert!(Display::load(&dir.join("missing.icc").to_string_lossy()).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}
+486 -72
View File
@@ -600,6 +600,11 @@ pub struct ExportOptions {
/// Bits per channel: 8, 16 (PNG, TIFF), 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, 32-bit float TIFF with the highlights above SDR
/// white kept. Other formats, and photos without an HDR edit, are SDR.
pub hdr: bool,
}
impl Default for ExportOptions {
@@ -623,6 +628,7 @@ impl Default for ExportOptions {
watermark: None,
color_space: OutputSpace::Srgb,
bit_depth: None,
hdr: false,
}
}
}
@@ -658,6 +664,7 @@ pub const OPTION_PARAMS: &[&str] = &[
"watermark",
"colorSpace",
"bitDepth",
"hdr",
];
/// The keys of a `watermark` object ([`Watermark`], camelCase).
@@ -789,7 +796,7 @@ impl ExportOptions {
}
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
}
"dontEnlarge" | "removeLocation" | "background" => {
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
boolean(k, v)?;
}
"naming" | "subfolder" | "path" | "dir" | "preset" => {
@@ -922,6 +929,7 @@ impl ExportOptions {
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
}
}
@@ -990,8 +998,10 @@ impl ExportOptions {
(ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16,
(ExportFormat::Png, _) => OutputDepth::U8,
(ExportFormat::Tiff, Some(8)) => OutputDepth::U8,
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
(ExportFormat::Tiff, _) => OutputDepth::U16,
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
(ExportFormat::Avif, _) => OutputDepth::U8,
}
@@ -1007,9 +1017,19 @@ impl ExportOptions {
}
}
/// The colour space the file is actually written in (AVIF: sRGB).
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
/// (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::Tiff, Some(32)))
}
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR).
pub fn effective_space(&self) -> OutputSpace {
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
match self.format {
ExportFormat::Avif if self.hdr => OutputSpace::Rec2020,
ExportFormat::Avif => OutputSpace::Srgb,
_ => self.color_space,
}
}
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
@@ -1119,13 +1139,8 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP.
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
let mut img = img.clone();
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let img = finish_8bit(img, o);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
let profile = icc::write_named(named_space(space));
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
@@ -1168,6 +1183,56 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
r.map_err(|e| e.to_string())
}
/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`.
fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {
let mut img = img.clone();
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
let space = o.effective_space();
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
draw_watermark(&mut img, &wm);
}
img
}
/// Output sharpening and the watermark on a high-bit-depth render.
fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage {
let mut img = img.clone();
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
draw_watermark_deep(&mut img, &wm);
}
img
}
/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space)
/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured
/// against the base as written, so its 8-bit rounding is handed back by the map.
pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
use lightcraft_codecs::gainmap;
let base = finish_8bit(sdr, o);
let hdr = finish_deep(hdr, o);
let DeepSamples::F32(hv) = &hdr.samples else {
return Err("HDR export needs a float HDR render".into());
};
let (w, h) = (base.width, base.height);
if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 {
return Err("HDR and SDR renders differ in size".into());
}
let space = o.effective_space();
let trc = space.trc();
let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect();
let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0;
let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?;
let profile = icc::write_named(named_space(space));
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string())
}
/// The codecs' name of an output space (for its ICC profile).
pub fn named_space(s: OutputSpace) -> NamedSpace {
match s {
@@ -1204,12 +1269,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 mut img = img.clone();
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
if let Some(wm) = &o.watermark {
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
draw_watermark_deep(&mut img, &wm);
}
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),
DeepSamples::U16(_) => icc::write_named(named_space(img.space)),
@@ -1222,27 +1282,21 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)),
DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),
};
let r = match o.format {
ExportFormat::Png => encode::encode_png(&e, &meta),
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
f => return Err(format!("{f:?} export is 8-bit only")),
let r = match (o.format, &img.samples) {
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
}
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
(f, _) => return Err(format!("{f:?} export is 8-bit only")),
};
r.map_err(|e| e.to_string())
}
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
fn parse_shutter(s: &str) -> Option<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
None => s.parse().ok(),
}
.filter(|v: &f64| v.is_finite() && *v > 0.0)
}
/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing.
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`).
pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option<Metadata> {
let m = &photo.meta;
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
// copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL
@@ -1270,7 +1324,9 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) ->
out.state = text(&m.state);
out.country = text(&m.country);
}
out.keywords = m.keywords.clone();
let keywords = catalog.export_keywords(&m.keywords);
out.keywords = keywords.flat;
out.hierarchical_keywords = keywords.hierarchical;
out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso);
out.rating = (photo.rating > 0).then_some(photo.rating as i8);
// Pixels are exported upright: orientation is baked in.
@@ -1283,7 +1339,7 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) ->
out.lens_model = text(&m.lens);
out.focal_length = m.focal_mm.map(f64::from);
out.f_number = m.aperture.map(f64::from);
out.exposure_time = parse_shutter(&m.shutter);
out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter);
out.iso = m.iso;
}
Some(out)
@@ -1324,10 +1380,15 @@ pub struct PreparedExport {
work: Work,
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
guard: std::sync::Arc<crate::originals::OriginalGuard>,
/// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`]
/// while it runs beside other photos of a batch.
weight: usize,
}
struct RenderWork {
job: crate::media::RenderJob,
/// The HDR rendition of a gain map JPEG (`job` renders its SDR base).
hdr_job: Option<crate::media::RenderJob>,
meta: Option<Metadata>,
opts: ExportOptions,
}
@@ -1373,25 +1434,52 @@ fn prepare_guarded(
) -> Result<PreparedExport, String> {
let p = session.catalog.photo(id).ok_or("no such photo")?;
let file_name = o.file_name_for(p, seq);
let work = if o.format.is_rendered() {
// HDR output only for photos edited in HDR; the rest of the batch exports as usual
let sdr_opts;
let o = if o.hdr && !p.develop.hdr.enabled {
sdr_opts = ExportOptions { hdr: false, ..o.clone() };
&sdr_opts
} else {
o
};
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
let (work, weight) = if o.format.is_rendered() {
let (w, h) = output_size(p, o);
let meta = export_metadata(p, o);
let meta = export_metadata(p, &session.catalog, o);
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() }))
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
} else {
let lightcraft_catalog::Source::File { path } = &p.source else {
return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name));
};
Work::File {
let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression);
// the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again
let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 });
let work = Work::File {
path: path.clone(),
read: session.media.file_bytes.clone(),
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
dng,
label: p.file_name.clone(),
size: (p.width as usize, p.height as usize),
}
};
(work, weight)
};
Ok(PreparedExport { photo: id, file_name, work, guard })
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
}
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB.
fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize {
let edge = level.max_edge().min(long.max(1));
let scale = edge as f64 / long.max(1) as f64;
let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize;
src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36))
}
impl PreparedExport {
@@ -1399,9 +1487,16 @@ impl PreparedExport {
let file_name = self.file_name;
match self.work {
Work::Render(w) => {
let RenderWork { job, meta, opts } = *w;
let RenderWork { job, hdr_job, meta, opts } = *w;
let r = job.run().rendered?;
let bytes = encode_rendered(&r, &opts, meta.as_ref())?;
let bytes = match hdr_job {
Some(hj) => {
let hr = hj.run().rendered?;
let hdr = hr.deep.ok_or("the HDR render has no float samples")?;
encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())?
}
None => encode_rendered(&r, &opts, meta.as_ref())?,
};
Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() })
}
Work::File { path, read, packet, dng, label, size } => {
@@ -1501,6 +1596,11 @@ fn sidecar_path(main: &str, ext: &str) -> String {
/// policy. `progress(done, next file)` is called before each photo; returning false cancels the
/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`,
/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`.
///
/// Several photos render side by side ([`export_parallelism`], within the memory of
/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's
/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the
/// files are the same as one at a time.
pub fn run_batch(
items: Vec<PreparedExport>,
o: &ExportOptions,
@@ -1510,31 +1610,226 @@ pub fn run_batch(
stop_on_error: bool,
progress: &mut dyn FnMut(usize, &str) -> bool,
) -> Result<Vec<serde_json::Value>, String> {
use serde_json::json;
let join = |a: &str, b: &str| if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) };
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join(&to.dir, &o.subfolder) };
let single = items.len() == 1;
let mut taken = std::collections::HashSet::new();
let mut out = Vec::new();
for (i, item) in items.into_iter().enumerate() {
if !progress(i, &item.file_name) {
break;
let lanes = export_parallelism(items.len());
run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes)
}
/// How many photos of a batch of `n` render side by side. Each render already spreads its rows
/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU.
/// One on wasm32 (no threads).
pub fn export_parallelism(n: usize) -> usize {
if cfg!(target_arch = "wasm32") {
return 1;
}
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
(cores / 4).clamp(1, 4).min(n.max(1))
}
/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them).
fn run_batch_with(
items: Vec<PreparedExport>,
placer: &mut Placer<'_>,
progress: &mut dyn FnMut(usize, &str) -> bool,
lanes: usize,
) -> Result<Vec<serde_json::Value>, String> {
placer.single = items.len() == 1;
if lanes <= 1 || items.len() <= 1 {
for (i, item) in items.into_iter().enumerate() {
if !progress(i, &item.file_name) {
break;
}
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
placer.place(photo, name, &guard, run_gated(item))?;
}
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
let e = match item.run() {
Ok(e) => e,
Err(err) if stop_on_error => return Err(err),
Err(err) => {
out.push(json!({"photo": photo.0, "file": name, "error": err}));
return Ok(std::mem::take(&mut placer.out));
}
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect();
let queue = Queue {
lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }),
cv: std::sync::Condvar::new(),
ahead: lanes,
};
std::thread::scope(|sc| {
for k in 1..lanes {
let q = &queue;
// a lane that can't start leaves its photos to the others (and to this thread)
if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() {
break;
}
}
let placed = || {
for (i, (photo, name, guard)) in heads.into_iter().enumerate() {
if !progress(i, &name) {
break;
}
let e = queue.result(i);
placer.place(photo, name, &guard, e)?;
}
Ok::<_, String>(())
};
let r = placed();
queue.stop();
r
})?;
Ok(std::mem::take(&mut placer.out))
}
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
/// becomes that photo's error, not the batch's end.
fn run_gated(item: PreparedExport) -> Result<Exported, String> {
let _held = crate::memory::export_gate().acquire(item.weight);
let what = format!("exporting {}", item.file_name);
crate::guard::catch(&what, || item.run()).and_then(|r| r)
}
/// The photos of a batch shared by its lanes: each lane takes the next one, at most
/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for
/// their turn too (their working memory is given back to the gate as soon as they are encoded).
struct Queue {
lane: std::sync::Mutex<Lane>,
cv: std::sync::Condvar,
ahead: usize,
}
struct Lane {
items: Vec<Option<PreparedExport>>,
/// The next photo to start.
next: usize,
/// Photos handed to the placer so far.
placed: usize,
stop: bool,
done: std::collections::HashMap<usize, Result<Exported, String>>,
}
impl Lane {
fn take_next(&mut self) -> Option<(usize, PreparedExport)> {
let i = self.next;
let item = self.items.get_mut(i)?.take()?;
self.next += 1;
Some((i, item))
}
}
impl Queue {
fn lock(&self) -> std::sync::MutexGuard<'_, Lane> {
self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> {
self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn finish(&self, i: usize, r: Result<Exported, String>) {
self.lock().done.insert(i, r);
self.cv.notify_all();
}
/// A lane thread: run photos until none is left or the batch stops.
fn work(&self) {
loop {
let mut l = self.lock();
let (i, item) = loop {
if l.stop || l.next >= l.items.len() {
return;
}
if l.next < l.placed.saturating_add(self.ahead) {
match l.take_next() {
Some(t) => break t,
None => return,
}
}
l = self.wait(l);
};
drop(l);
self.finish(i, run_gated(item));
}
}
/// Photo `i`'s result, waiting for it; run here when no lane has started it yet.
fn result(&self, i: usize) -> Result<Exported, String> {
let mut l = self.lock();
loop {
if let Some(r) = l.done.remove(&i) {
l.placed = i + 1;
drop(l);
self.cv.notify_all();
return r;
}
if l.next == i {
let Some((_, item)) = l.take_next() else {
return Err("export: photo missing from the batch".into());
};
drop(l);
self.finish(i, run_gated(item));
l = self.lock();
continue;
}
l = self.wait(l);
}
}
fn stop(&self) {
self.lock().stop = true;
self.cv.notify_all();
}
}
/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an
/// original) and its write, in batch order.
struct Placer<'a> {
o: &'a ExportOptions,
to: &'a Destination,
dir: String,
single: bool,
taken: std::collections::HashSet<String>,
out: Vec<serde_json::Value>,
write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
exists: &'a dyn Fn(&str) -> bool,
stop_on_error: bool,
}
fn join_path(a: &str, b: &str) -> String {
if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) }
}
impl<'a> Placer<'a> {
fn new(
o: &'a ExportOptions,
to: &'a Destination,
write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
exists: &'a dyn Fn(&str) -> bool,
stop_on_error: bool,
) -> Placer<'a> {
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) };
Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
}
/// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err`
/// only when it failed and `stop_on_error` is set.
fn place(
&mut self,
photo: lightcraft_catalog::PhotoId,
name: String,
guard: &crate::originals::OriginalGuard,
e: Result<Exported, String>,
) -> Result<(), String> {
use serde_json::json;
let (o, dir, exists) = (self.o, &self.dir, self.exists);
let e = match e {
Ok(e) => e,
Err(err) if self.stop_on_error => return Err(err),
Err(err) => {
self.out.push(json!({"photo": photo.0, "file": name, "error": err}));
return Ok(());
}
};
// the exported file and its sidecars
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
let path = match to.exact.as_deref().filter(|_| single) {
let path = match self.to.exact.as_deref().filter(|_| self.single) {
Some(p) => Ok(p.to_string()),
None => {
let path = join(&dir, &e.file_name);
let path = join_path(dir, &e.file_name);
let taken = &self.taken;
let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p));
if !busy(&path) {
Ok(path)
@@ -1542,12 +1837,12 @@ pub fn run_batch(
match o.conflict {
Conflict::Overwrite => Ok(path),
Conflict::Skip => {
out.push(json!({"skipped": path}));
continue;
self.out.push(json!({"skipped": path}));
return Ok(());
}
Conflict::Unique => {
let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b)));
let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}")));
(2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name"))
}
}
@@ -1555,6 +1850,7 @@ pub fn run_batch(
}
};
let file = path.clone().unwrap_or_else(|_| name.clone());
let write = &mut *self.write;
let written = path.and_then(|path| {
let files = group(&path);
// never over an original, whatever the conflict policy or the exact path said
@@ -1571,14 +1867,14 @@ pub fn run_batch(
});
match written {
Ok((path, files, sidecars)) => {
taken.extend(files);
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
self.taken.extend(files);
self.out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
}
Err(err) if stop_on_error => return Err(err),
Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})),
Err(err) if self.stop_on_error => return Err(err),
Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})),
}
Ok(())
}
Ok(out)
}
#[cfg(test)]
@@ -1626,9 +1922,28 @@ mod tests {
assert!(img.data[3][0] < 80 && img.data[4][0] > 170);
}
/// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject`
/// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of
/// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`.
#[test]
fn exported_keywords_follow_their_options() {
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo};
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()];
let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap();
assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]);
assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]);
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap();
let back = lightcraft_meta::extract(&jpg);
assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone()));
}
#[test]
fn metadata_policies() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_catalog::{Catalog, Photo, PhotoId, Source};
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
p.meta.camera = "Synthetic X2".into();
p.meta.copyright = "(c) Me".into();
@@ -1637,21 +1952,21 @@ mod tests {
p.meta.copyright_url = "https://example.com/rights".into();
p.meta.shutter = "1/250".into();
p.meta.gps = Some((43.0, -110.0));
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap();
assert_eq!(all.model.as_deref(), Some("Synthetic X2"));
assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9);
assert!(all.gps.is_some());
let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() };
let m = export_metadata(&p, &o).unwrap();
let m = export_metadata(&p, &Catalog::new(), &o).unwrap();
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
// "copyright only" keeps all the copyright info: status, usage terms, info URL
assert_eq!(
(c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()),
(Some(true), Some("Editorial use only"), Some("https://example.com/rights"))
);
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
// embedded and readable back from the JPEG
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
let back = lightcraft_meta::extract(&jpg);
@@ -2225,4 +2540,103 @@ mod tests {
assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15);
assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0);
}
/// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there):
/// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`.
#[allow(clippy::type_complexity)]
fn lanes_batch(
s: &mut crate::Session,
ids: &[lightcraft_catalog::PhotoId],
o: &ExportOptions,
lanes: usize,
stop_on_error: bool,
cancel_at: usize,
) -> (Result<Vec<serde_json::Value>, String>, Vec<(String, Vec<u8>)>, Vec<(usize, String)>) {
let items = prepare_batch(s, ids, o).unwrap();
let to = Destination { dir: "out".into(), exact: None };
let mut written = Vec::new();
let mut write = |p: &str, b: &[u8]| {
written.push((p.to_string(), b.to_vec()));
Ok(())
};
let mut seen = Vec::new();
let mut progress = |i: usize, name: &str| {
seen.push((i, name.to_string()));
i < cancel_at
};
let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes);
(files, written, seen)
}
/// The files of a batch without their byte counts: a GPU render (when another test turns the
/// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order.
fn shape(files: &[serde_json::Value]) -> Vec<serde_json::Value> {
files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect()
}
// Issue #496: photos rendered side by side give the same files, names, order and progress as
// one at a time, and a cancel stops at the same photo
#[test]
fn side_by_side_batches_match_one_at_a_time() {
let mut s = crate::Session::with_demo();
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
// one name for all: the Unique numbering follows the order the files are placed in
let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"}));
let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX);
let files = files.unwrap();
let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect();
assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]);
let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect();
assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}");
for lanes in [2, 3, 8] {
let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX);
assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes");
assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>(), paths, "{lanes} lanes");
assert_eq!(sn, seen, "{lanes} lanes");
}
let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2);
assert_eq!(shape(&f.unwrap()), shape(&files[..2]));
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
}
// A photo that fails is reported in its place while the others are exported; with
// stop_on_error the batch ends there and nothing after it is written
#[test]
fn side_by_side_batches_report_failures_in_order() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let mut s = crate::Session::with_demo();
let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect();
let gone =
Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap();
ids.insert(1, PhotoId(9_496));
let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40}));
let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX);
let files = files.unwrap();
assert_eq!(files.len(), 4);
assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}");
assert!(files[1]["error"].is_string());
assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}");
assert_eq!(written.len(), 3);
let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap();
assert_eq!(shape(&files), shape(&one));
let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX);
assert!(r.is_err());
assert_eq!(written.len(), 1, "only the photo before the failure");
}
#[test]
fn export_weights_follow_the_source_and_output_sizes() {
use crate::media::SourceLevel;
let mb = |b: usize| b >> 20;
// a full-size 24 MP export (measured peak ~1.2 GB)
assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098);
// 2048 px from the 2560 px preview (measured ~150 MB)
assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145);
// a photo smaller than the preview level is its own source
assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12);
assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0);
assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow");
}
}
+76 -4
View File
@@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use crate::activity::{Activity, Cancel, TaskHandle, Unit};
use lightcraft_faces::known::Download;
/// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it
@@ -42,15 +44,20 @@ pub enum State {
struct Shared {
state: Mutex<State>,
cancel: AtomicBool,
cancel: Arc<AtomicBool>,
/// The download's row in the activity stack.
task: Option<TaskHandle>,
}
impl Shared {
fn new(total: u64) -> Self {
Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) }
Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: Arc::new(AtomicBool::new(false)), task: None }
}
fn set(&self, s: State) {
if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) {
task.progress(*bytes, *total);
}
*self.state.lock().unwrap_or_else(PoisonError::into_inner) = s;
}
@@ -68,18 +75,27 @@ pub struct Downloads {
impl Downloads {
/// Start fetching `spec` into `<models dir>/.downloads/`. Different models can download side by side; the same
/// one is started once.
pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> {
/// The activity stack shows it (as `name`) until the thread ends; its ✕ stops it like [`Downloads::discard`].
pub fn start(&mut self, spec: Download, models_dir: &Path, activity: &Activity, name: &str) -> Result<(), String> {
if self.jobs.get(spec.id.as_str()).is_some_and(|j| matches!(j.get(), State::Running { .. })) {
return Err("that model is already downloading".into());
}
let staging = models_dir.join(STAGING);
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?;
let shared = Arc::new(Shared::new(spec.size_bytes));
let cancel = Arc::new(AtomicBool::new(false));
let task = activity.start("download", "Downloading model", Cancel::Flag(cancel.clone()));
task.set_unit(Unit::Bytes);
task.detail(name);
task.progress(0, spec.size_bytes);
let total = spec.size_bytes;
let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) });
let worker = shared.clone();
let id = spec.id.clone();
std::thread::Builder::new()
.name("face-model-download".into())
.spawn(move || {
// the row goes when the thread ends, however it ends
let _task = task;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker)));
if outcome.is_err() {
worker.set(State::Failed("the download stopped unexpectedly".into()));
@@ -267,6 +283,62 @@ mod tests {
base
}
/// A local server that sends `body` a few kilobytes at a time: a download that keeps running for a while.
fn serve_slowly(body: Vec<u8>) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/m", l.local_addr().unwrap());
std::thread::spawn(move || {
for mut s in l.incoming().flatten() {
let mut r = BufReader::new(s.try_clone().unwrap());
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
break;
}
}
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());
for chunk in body.chunks(4096) {
if s.write_all(chunk).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
}
});
base
}
/// Poll until `done` holds (or fail after 10 s).
fn wait(what: &str, done: impl Fn() -> bool) {
let t = std::time::Instant::now();
while !done() {
assert!(t.elapsed() < Duration::from_secs(10), "{what}");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_running_download_shows_in_activity_and_cancels() {
use crate::activity::{Activity, Unit};
let dir = scratch("activity");
let bytes: Vec<u8> = (0..400_000u32).map(|i| (i % 251) as u8).collect();
let mut spec = spec_for(&bytes);
spec.url = format!("{}/{}", serve_slowly(bytes), spec.file_name);
let activity = Activity::default();
let mut d = Downloads::default();
d.start(spec, &dir, &activity, "Test Model").unwrap();
let rows = activity.list();
assert_eq!(rows.len(), 1);
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "Test Model"));
assert_eq!((rows[0].unit, rows[0].total), (Unit::Bytes, 400_000));
assert!(rows[0].cancellable);
activity.cancel(rows[0].id).unwrap();
wait("the download never stopped", || !d.running());
assert_eq!(d.jobs["test-model"].get(), State::Cancelled);
wait("the row stayed", || activity.list().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fetches_checks_and_stages_a_file() {
let dir = scratch("ok");
+4
View File
@@ -434,6 +434,10 @@ pub(crate) struct FacesState {
pub scanned: Scanned,
/// Photos that could not be searched this session (unreadable files): left alone until the next launch.
pub scan_failed: std::collections::HashSet<u64>,
/// The scan's row in the activity stack, while photos are left.
pub task: Option<crate::activity::TaskGuard>,
/// The most photos left at once since the scan last finished (the row's whole).
pub peak: u64,
}
impl FacesState {
+442 -34
View File
@@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
usage_terms: m.usage_terms.clone().unwrap_or_default(),
copyright_url: m.copyright_url.clone().unwrap_or_default(),
creator: m.artist.clone().unwrap_or_default(),
keywords: m.keywords.clone(),
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
regions: m.regions.clone(),
};
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
@@ -151,8 +152,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
if !fmt.can_decode() {
return Err(format!("{fmt:?} files are not supported yet"));
if let Some(why) = fmt.not_decodable() {
// e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft"
return Err(format!("{fmt:?} files can't be opened: {why}"));
}
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
@@ -200,6 +202,92 @@ fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, S
/// Sensor clip level (normalised) for highlight reconstruction.
const HIGHLIGHT_CLIP: f32 = 0.99;
/// How a raw's camera RGB becomes the linear Rec.2020 the pipeline takes.
pub(crate) struct CameraColourModel<'a> {
/// White-balance multipliers (as shot).
pub wb: [f32; 3],
/// White-balanced camera RGB → linear Rec.2020: the camera matrix, or a look fitted to the camera's JPEG on top.
pub matrix: lightcraft_color::Mat3,
/// Baseline exposure, as a gain.
pub gain: f32,
/// A DNG profile's hue/saturation map and look table.
pub tables: Option<&'a lightcraft_raw::profile::ProfileTables>,
/// A fitted camera look's hue/saturation table.
pub hue_sat: Option<&'a crate::camera_preview::HueSat<'a>>,
}
impl CameraColourModel<'_> {
/// Rebuild sensor-clipped channels; fully clipped highlights become [`clipped_white`] of the matrix, so they
/// come out of [`Self::apply`] neutral.
pub fn rebuild_highlights(&self, img: &mut Rgb32f) {
lightcraft_raw::highlight::reconstruct_with(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix));
}
/// [`Self::rebuild_highlights`] for a binned image whose clipped channels `mask` tells
/// ([`lightcraft_raw::RawImage::develop_binned_masked`]).
pub fn rebuild_highlights_masked(&self, img: &mut Rgb32f, mask: &[u8]) {
lightcraft_raw::highlight::reconstruct_masked(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix), mask);
}
/// White balance, matrix, tables and baseline exposure, in place.
pub fn apply(&self, img: &mut Rgb32f) {
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match self.tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
});
}
}
/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders
/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out
/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a
/// camera profile often tints it by a few per cent, which any darkening of the highlights would show).
/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not
/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per
/// cent), or one that isn't neutral through the `f32` matrix the decode applies.
pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] {
const CAMERA: [f32; 3] = [1.0; 3];
/// Largest condition number (max row sum norm) of a model trusted for this.
const CONDITION: f64 = 1e3;
/// Largest factor between the answer and the camera's neutral, per channel.
const SPREAD: f64 = 4.0;
let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::<f64>()).fold(0.0, f64::max);
let Some(inverse) = model.inverse() else { return CAMERA };
let condition = norm(model) * norm(&inverse);
if !condition.is_finite() || condition > CONDITION {
return CAMERA;
}
let luma = |v: [f64; 3]| {
let c = model.apply(v);
(0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::<f64>()
};
let n = inverse.apply([1.0; 3]);
let (target, own) = (luma([1.0; 3]), luma(n));
if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) {
return CAMERA;
}
let white = n.map(|v| v * target / own);
if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) {
return CAMERA;
}
let white = white.map(|v| v as f32);
// neutral through the matrix as the decode applies it
let m = model.to_f32();
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]);
let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2]));
if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA }
}
/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels:
/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the
/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the
@@ -237,8 +325,9 @@ pub fn load_vec_pair(bytes: Vec<u8>, max_edge: usize, spec: &DenoiseSpec) -> Res
}
/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default
/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned. `None` when the product
/// is missing, damaged, made from something else or not the size of this raw's active area.
/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the
/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when
/// the product is missing, damaged, made from something else or not the size of this raw's active area.
fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option<Rgb32f> {
use lightcraft_denoise::product::{self, Window};
let a = raw.active_area;
@@ -249,7 +338,7 @@ fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, facto
if raw.opcodes.list3.is_empty() {
let c = raw.develop_crop(a.width, a.height);
let window = Window { x: c.x, y: c.y, width: c.width, height: c.height };
product::read_window(&spec.product, &spec.key, Some(window), factor, Some(HIGHLIGHT_CLIP)).ok()
product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok()
} else {
// (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1)
let whole = product::read(&spec.product, &spec.key, 1).ok()?;
@@ -275,59 +364,56 @@ fn load_bytes_now(
};
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
let lens = embedded_lens(&raw.info());
let info = raw.info();
let lens = embedded_lens(&info);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
// the source's segmentation mattes (DNG semantic masks), read while the preview is fitted
let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw));
// Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`).
let own_matrix = lightcraft_raw::color::has_matrix(&raw.color);
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info));
drop(bytes);
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
// larger levels (exports, 1:1) demosaic the whole sensor.
let t0 = web_time::Instant::now();
let bin = bin_factor(&raw, max_edge);
// (binned: block means, and which colours had a clipped sample in each block)
let binned = match bin {
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
None => None,
};
let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 };
let img = match binned {
Some(img) => img,
let (img, clip_mask) = match binned {
Some((img, mask)) => (img, Some(mask)),
None => {
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
raw.develop(method).map_err(|e| e.to_string())?
(raw.develop(method).map_err(|e| e.to_string())?, None)
}
};
// the same development from the denoised mosaic, when the photo has been denoised
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
// the samples aren't needed any more (the colour model below reads only the tags)
raw.data = lightcraft_raw::RawData::U16(Vec::new());
let wb = t.wb;
let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
let gain = 2f32.powf(t.baseline_exposure as f32);
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
let colour = CameraColourModel {
wb: t.wb,
matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix),
gain: 2f32.powf(t.baseline_exposure as f32),
tables: tables.as_ref(),
hue_sat: hue_sat.as_ref(),
};
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
let finish = |mut img: Rgb32f| {
let mut stages = vec![("develop", t0.elapsed())];
stages.push(("transform", t0.elapsed()));
lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP);
match &clip_mask {
Some(mask) => colour.rebuild_highlights_masked(&mut img, mask),
None => colour.rebuild_highlights(&mut img),
}
stages.push(("highlights", t0.elapsed()));
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match &tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
});
colour.apply(&mut img);
stages.push(("colour", t0.elapsed()));
let img = fit(&img, max_edge, max_edge, Filter::Box);
stages.push(("fit", t0.elapsed()));
@@ -350,7 +436,7 @@ fn load_bytes_now(
}
let twin = twin.map(|picture| finish(picture).0);
let (temp, tint) = xy_to_temp_tint(xy);
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix;
// White balance re-evaluates the file's own colour model (when it has one and no
// file-local look matrix sits on top of it)
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
@@ -398,7 +484,7 @@ fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline:
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::masks::Mattes>> {
fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option<Arc<lightcraft_pipeline::masks::Mattes>> {
use lightcraft_pipeline::masks::{MatteKind, Mattes};
if raw.format != lightcraft_raw::RawFormat::Dng {
return None;
@@ -587,6 +673,19 @@ impl crate::Session {
#[cfg(test)]
mod tests {
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
/// them; an exported `travel|Italy|Rome` comes back as itself.
#[test]
fn imported_keywords_keep_their_hierarchy() {
let m = lightcraft_meta::Metadata {
keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()],
hierarchical_keywords: vec!["travel|Italy|Rome".into()],
..Default::default()
};
assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]);
}
use super::*;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
@@ -621,6 +720,37 @@ mod tests {
assert!(probe_bytes("x.jpg", b"not an image").is_err());
}
/// 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).
#[test]
fn coarse_binning_keeps_specular_points_neutral() {
let (w, h) = (256usize, 192usize);
// grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right
let neutral = [0.5f32, 1.0, 0.7];
let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap();
let mut data: Vec<u16> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize];
(256.0 + v * (16000.0 - 256.0)).round() as u16
})
.collect();
// one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block
for by in 0..h / 8 {
for bx in (by % 2..w / 8).step_by(2) {
data[(by * 8 + 4) * w + bx * 8 + 5] = 16000;
}
}
let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default());
let raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!(bin_factor(&raw, 32), Some(8));
let (img, _) = load_bytes(&bytes, 32).unwrap();
assert_eq!((img.width, img.height), (32, 24));
let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max);
assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels");
}
#[test]
fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() {
use lightcraft_denoise::product;
@@ -868,6 +998,240 @@ mod tests {
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
}
/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's
/// own white-balanced neutral comes out yellow (blue 12 % low).
fn yellowing_look() -> lightcraft_color::Mat3 {
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
}
#[test]
fn clipped_white_renders_neutral_through_the_colour_model() {
use lightcraft_color::{LUMA_2020, Mat3};
let look = yellowing_look();
let white = clipped_white(&look);
let out = look.apply(white.map(f64::from));
assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}");
let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::<f64>();
assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral");
assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]);
// a model that can't be inverted, or only just, keeps the camera's neutral
assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
// so does one whose white would overflow the rebuilt highlights (2.5e38 in red here)
assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
// or move the camera's neutral implausibly far
assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
// what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite
for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] {
let w = clipped_white(&model);
let m = model.to_f32();
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]);
assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}");
let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None };
let mut img = Rgb32f::filled(4, 4, [1.0; 3]);
colour.rebuild_highlights(&mut img);
colour.apply(&mut img);
assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0));
}
}
/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that
/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral;
/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it.
#[test]
fn clipped_highlights_stay_neutral_when_darkened() {
use lightcraft_pipeline::{RenderRequest, render};
let wb = [2.4f32, 1.0, 1.6];
let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None };
// a grey wall (camera neutral) with a fully clipped light and a light clipped in green only
let mut img = Rgb32f::from_fn(96, 64, |x, y| {
let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt();
if d(28.0) < 14.0 {
[1.0; 3]
} else if d(70.0) < 12.0 {
[0.6 / wb[0], 1.0, 0.7 / wb[2]]
} else {
[0.3 / wb[0], 0.3, 0.3 / wb[2]]
}
});
colour.rebuild_highlights(&mut img);
colour.apply(&mut img);
let info = SourceInfo { raw: true, ..Default::default() };
let mut issue = lightcraft_develop::DevelopSettings::default();
let l = &mut issue.light;
(l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0);
let mut h100 = lightcraft_develop::DevelopSettings::default();
h100.light.highlights = -100.0;
for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] {
let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image;
let p = out.get(28, 32);
assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})");
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral");
}
}
/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`)
/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise
/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last
/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row.
fn darkened_ramp(rows: std::ops::Range<usize>, height: usize) -> ([[u8; 4]; 3], u8) {
use lightcraft_pipeline::{RenderRequest, render};
let wb = [2.5f32, 1.0, 1.6];
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
let w = 1600usize;
let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32;
let sensor =
Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] });
let y = (rows.start + rows.end) / 2;
let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap();
let mut img = sensor.clone();
colour.rebuild_highlights(&mut img);
colour.apply(&mut img);
let mut s = lightcraft_develop::DevelopSettings::default();
s.light.exposure = -2.0;
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image;
let step = |x: usize| {
let (a, b) = (out.get(x - 1, y), out.get(x, y));
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
};
([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0))
}
/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards
/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels
/// there.
#[test]
fn rendered_colour_is_continuous_where_the_first_channel_clips() {
let ([a, b, c], worst) = darkened_ramp(0..16, 16);
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}");
assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels");
}
/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there
/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however
/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163];
/// on 512 rows, from [225, 126, 153] to [218, 145, 151]).
#[test]
fn rendered_thin_line_is_continuous_where_the_first_channel_clips() {
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
for height in [32usize, 128, 512, 1024] {
for width in [1usize, 2, 4, 16] {
let top = (height - width) / 2;
let ([a, b, c], worst) = darkened_ramp(top..top + width, height);
let case = format!("{width} rows of {height}");
assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}");
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
}
}
}
/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1,
/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale
/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows
/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the
/// rendered colour on either side of where red clips on the band's middle row, and the largest step between
/// neighbouring pixels along that row.
fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) {
use lightcraft_pipeline::{RenderRequest, render};
let wb = [2.5f32, 1.0, 1.6];
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
let (w, h) = (320usize, top + strip + band);
let sensor = Rgb32f::from_fn(w, h, |x, y| {
if y < top {
[0.8, 0.35, 0.3]
} else if y < top + strip {
[0.4, 0.95, 0.8]
} else {
[0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8]
}
});
let y = top + strip + band / 2;
let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap();
let mut img = sensor.clone();
colour.rebuild_highlights(&mut img);
colour.apply(&mut img);
let mut s = lightcraft_develop::DevelopSettings::default();
s.light.exposure = -2.0;
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image;
let step = |x: usize| {
let (a, b) = (out.get(x - 1, y), out.get(x, y));
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
};
([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0))
}
/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The
/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly
/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then
/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped
/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]).
#[test]
fn rendered_colour_is_continuous_where_a_second_channel_clips() {
for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] {
let ([a, b], worst) = second_clip(top, strip, band);
let case = format!("{top} rows of colour, {strip} of rim, {band} of band");
let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0);
assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips");
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
}
}
/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in
/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo
/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them).
#[test]
fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() {
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");
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let (img, _) = load_bytes(&bytes, 1600).unwrap();
let (w, h) = (img.width, img.height);
let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p);
let chroma = |p: [f32; 3]| {
let s = (p[0] + p[1] + p[2]).max(1e-6);
[p[0] / s, p[2] / s]
};
// the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame
let px = |f: f32, n: usize| (f * n as f32) as usize;
let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]);
let mut rows = 0;
for y in px(0.24, h)..px(0.34, h) {
let row = &img.data[y * w..(y + 1) * w];
let sky = lum(row[px(0.2, w)]);
let dark: Vec<usize> = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect();
let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue };
let k = w as f32 / 6000.0;
let mean = |a: usize, b: usize| {
let n = (b - a) as f64;
(a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n])
};
let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize);
for (side, (n, f)) in
[(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))]
.into_iter()
.enumerate()
{
for c in 0..2 {
near[side][c] += n[c];
far[side][c] += f[c];
}
}
rows += 1;
}
assert!(rows > 50, "the tree was not found ({rows} rows)");
for side in 0..2 {
let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max);
assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]);
}
}
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
/// heuristic would never look) is used; a matte with an unknown name is ignored.
@@ -1143,6 +1507,26 @@ mod tests {
let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap();
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
// TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281)
for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
ifd0.set(t::MAKE, Value::Ascii(make.into()));
let px: Vec<u8> = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] });
let bytes = TiffWriter::default().write(&[ifd0]).unwrap();
let p = probe_bytes("photo.tif", &bytes).unwrap();
assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}");
let (img, src) = load_bytes(&bytes, 24).unwrap();
assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}");
}
}
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
@@ -1166,6 +1550,30 @@ mod tests {
assert!(e.contains("without an embedded preview"), "{e}");
}
/// 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]
fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803]));
ifd.set(t::COMPRESSION, Value::Short(vec![9]));
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] });
let f = TiffWriter::default().write(&[ifd]).unwrap();
let p = probe_bytes("GOPR0001.GPR", &f).unwrap();
assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16));
let e = load_bytes(&f, 16).unwrap_err();
assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}");
}
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
/// CR3 keeps its own wording.
#[test]
+21 -6
View File
@@ -33,7 +33,10 @@ use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
"jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"jxl", "gif", "bmp",
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
// failed with the reason ("HEIC/HEIF support isn't included in this build").
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
];
@@ -498,7 +501,8 @@ pub struct ScanProgress {
/// Files to probe, set once the folders are expanded.
pub total: std::sync::atomic::AtomicUsize,
pub done: std::sync::atomic::AtomicUsize,
pub cancel: std::sync::atomic::AtomicBool,
/// Shared with the scan's row in the activity stack (its ✕ sets it).
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
@@ -850,8 +854,17 @@ impl ImportJob {
}
}
// files a preceding scan already probed are not read again (a network share is slow to read)
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
// files a preceding scan already probed are not read again (a network share is slow to read),
// as long as they are still there, as they were: a file gone or changed since is probed again
let cached: Vec<Option<ProbeInfo>> = todo
.iter()
.map(|f| {
// (the web's files are not std::fs files: kept as probed)
self.cache
.remove(f)
.filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size))
})
.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();
@@ -899,7 +912,8 @@ impl ImportJob {
let packet = crate::sidecar::find_sidecar(&path, self.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some());
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
Ok(sc) => Some(sc),
Err(e) => {
log::warn!("import {path}: XMP: {e}");
@@ -1044,7 +1058,7 @@ fn name_family(name: &str) -> String {
/// Threads for `jobs` file reads (probes, copies) at once.
#[cfg(not(target_arch = "wasm32"))]
fn workers(jobs: usize) -> usize {
pub(crate) fn workers(jobs: usize) -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
}
@@ -1371,6 +1385,7 @@ impl Session {
cache: None,
stages: None,
view_cache: None,
display: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
+37 -4
View File
@@ -14,16 +14,19 @@
#[cfg(not(feature = "denoise"))]
extern crate lightcraft_denoise_core as lightcraft_denoise;
pub mod activity;
pub mod availability;
mod camera_preview;
pub(crate) mod camera_preview;
pub mod camera_profiles;
pub mod cmd;
pub mod config;
pub mod contact_sheet;
pub mod crs;
pub mod crs_masks;
pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
pub mod export;
pub mod face_download;
#[cfg(not(target_arch = "wasm32"))]
@@ -53,6 +56,7 @@ pub mod rename;
pub mod segment;
pub mod sidecar;
pub mod smart;
pub mod sync;
mod view;
pub mod walk;
@@ -166,6 +170,9 @@ pub struct Session {
/// Photos in the current source with no filter on (cached like `visible`).
total: Option<((u64, String), usize)>,
pub undo: Vec<UndoEntry>,
/// Undo steps committed in this session, ever (the history drops its oldest past 1000, so
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
commits: u64,
pub redo: Vec<UndoEntry>,
pub interaction: Option<Interaction>,
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
@@ -252,6 +259,12 @@ pub struct Session {
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
/// File probes from the last import review (`library.importPreview`), reused by the import.
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
/// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current
/// (see [`Session::take_folder_changes`]).
pub folder_changes: Option<sync::FolderChanges>,
/// Background tasks in flight (imports, exports, preview builds…), for the activity stack and
/// `activity.list` / `activity.cancel`.
pub activity: activity::Activity,
/// The last (or running) Build Previews.
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// Develop defaults applied on import (persisted in prefs.json).
@@ -290,6 +303,7 @@ impl Session {
visible_gen: 0,
total: None,
undo: Vec::new(),
commits: 0,
redo: Vec::new(),
interaction: None,
skip_auto_write: false,
@@ -334,6 +348,8 @@ impl Session {
recent_keywords: Vec::new(),
before: Default::default(),
import_probes: Default::default(),
folder_changes: None,
activity: Default::default(),
preview_build: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
@@ -352,7 +368,10 @@ impl Session {
/// Run a command by id. THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
// a command that validates the photos a call names isn't held back by the selection
if !(spec.explicit_targets && cmd::names_photos(params)) {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
}
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
@@ -422,6 +441,7 @@ impl Session {
let inv = self.catalog.apply(op)?;
self.pending_log.push(fwd);
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
self.commits = self.commits.wrapping_add(1);
if self.undo.len() > 1000 {
self.undo.remove(0);
}
@@ -486,6 +506,12 @@ impl Session {
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
/// depends on the state the previous one left).
/// How many undo steps were committed in this session so far: what a command that commits
/// several steps compares before and after to merge them ([`Session::merge_undo`]).
pub fn commits(&self) -> u64 {
self.commits
}
pub fn merge_undo(&mut self, n: usize, label: &str) {
if n < 2 || n > self.undo.len() {
return;
@@ -626,7 +652,8 @@ impl Session {
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor history / snapshot restores.
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
/// history / snapshot restores.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
@@ -639,7 +666,7 @@ impl Session {
let Some(old) = self.develop_of(id) else { return Vec::new() };
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
if let Some(o) = delta.as_object_mut() {
for k in ["spots", "red_eye", "version"] {
for k in ["spots", "red_eye", "version", "process"] {
o.remove(k);
}
if o.is_empty() {
@@ -859,6 +886,8 @@ mod tests_import;
#[cfg(test)]
mod tests_import_move;
#[cfg(test)]
mod tests_keyword_list;
#[cfg(test)]
mod tests_libops;
#[cfg(test)]
mod tests_library;
@@ -871,10 +900,14 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
#[cfg(test)]
mod tests_spots;
#[cfg(test)]
mod tests_sync;
#[cfg(test)]
mod tests_xmp;
+5
View File
@@ -419,6 +419,11 @@ impl Session {
lib.forgot_local = Some(plan);
}
}
// smart previews written by an older camera-look fit are brought up to date in the background
#[cfg(not(target_arch = "wasm32"))]
if on_disk {
crate::cmd::previews::refresh_stale_smart_previews(self);
}
self.library_identity = std::sync::Arc::new(());
Ok(())
}
+51 -16
View File
@@ -478,7 +478,13 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
}
fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec<String>), String> {
fn mapped_settings(
text: &str,
target: crate::crs::Target,
aspect: f64,
orientation: Orientation,
stored_aspect: f64,
) -> Result<(Value, Vec<String>), String> {
let mut root = crate::preset_import::parse_lua(text)?;
let deferred = strip_lua_sentinels(&mut root);
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
@@ -488,7 +494,7 @@ fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation,
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
props.insert("crs:HasCrop".into(), vec!["True".into()]);
}
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
catalog_crop(&mut partial, orientation, stored_aspect);
if deferred > 0 {
unknown.push("Deferred Lightroom adjustments (-999999)".into());
@@ -717,7 +723,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
preserved += 1;
}
if !src.xmp.is_empty() {
match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) {
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
Ok(mut sc) => {
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
strip_xmp_sentinels(partial);
@@ -745,7 +751,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
match mapped_settings(
&src.settings,
p.kind == lightcraft_catalog::MediaKind::Raw,
crate::crs::Target::for_photo(&p),
p.width.max(1) as f64 / p.height.max(1) as f64,
orientation,
stored_aspect,
@@ -936,7 +942,10 @@ mod tests {
cyclic.collections[0].insert("parent".into(), json!(10));
assert!(apply(&mut s, cyclic, false).is_err());
assert_eq!(s.catalog.to_snapshot(), before);
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err());
assert!(
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
.is_err()
);
}
#[test]
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
@@ -946,17 +955,41 @@ mod tests {
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
encoded[0] = 0xff;
assert!(xmp(Some(&json!(encoded))).is_err());
let (partial, _) =
mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, _) = mapped_settings(
"s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }",
crate::crs::Target::RawAbsolute,
1.5,
Orientation::Normal,
1.5,
)
.unwrap();
assert_eq!(partial["light"]["exposure"], 0.75);
}
#[test]
fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
// Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
let settings =
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
assert_eq!(partial["light"]["exposure"], 0.5);
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert_eq!(
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
(Some("custom"), Some(3578.0), Some(-5.0))
);
assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}");
}
#[test]
fn catalog_crops_without_has_crop_are_imported() {
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
let (partial, unknown) = mapped_settings(
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
true,
crate::crs::Target::RawAbsolute,
1.5,
Orientation::Normal,
1.5,
@@ -970,11 +1003,11 @@ mod tests {
assert_eq!(crop["angle"], 0.0);
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
// no crop fields: the crop stays untouched
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert!(partial.get("crop").is_none());
}
@@ -998,7 +1031,7 @@ mod tests {
fn catalog_crops_follow_the_photo_orientation() {
let crop = |o: Orientation| {
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap();
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let g = &partial["crop"]["geometry"];
let r = &g["rect"];
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
@@ -1011,13 +1044,13 @@ mod tests {
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
// a mirror turns the straighten the other way
let angle = |o: Orientation| {
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap();
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
};
assert_eq!(angle(Orientation::Rotate90), -2.0);
assert_eq!(angle(Orientation::FlipH), 2.0);
// no crop: nothing to orient
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap();
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
assert!(partial.get("crop").is_none());
}
@@ -1043,7 +1076,7 @@ mod tests {
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
for angle in [6.5, -6.5] {
let g = CropGeometry { angle, ..g };
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap();
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(
@@ -1073,7 +1106,7 @@ mod tests {
(6100.0, 9150.0),
),
] {
let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap();
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
let (w, h) = (r.width() * ow, r.height() * oh);
@@ -1112,7 +1145,9 @@ mod tests {
assert_eq!(p.develop.light.shadows, 65.0);
assert_eq!(p.develop.light.blacks, -22.0);
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap();
let (partial, _) =
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
.unwrap();
assert_eq!(partial["light"]["exposure"], -5.0);
assert_eq!(partial["light"]["contrast"], -100.0);
}
+55 -5
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 = 22;
pub const RENDER_CACHE_VERSION: u64 = 42;
/// 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];
@@ -251,7 +251,7 @@ impl SourceRef {
pub fn load_source(&self) -> Result<DecodedSource, String> {
let image = match self {
SourceRef::Loaded(a) => return Ok((**a).clone()),
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge),
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
let plain = || match loader {
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
@@ -674,6 +674,8 @@ pub struct RenderJob {
pub stages: Option<Arc<StageCache>>,
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option<Arc<crate::display::Display>>,
}
pub struct RenderResult {
@@ -687,6 +689,9 @@ pub struct RenderResult {
pub loaded: Option<DecodedSource>,
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
pub quick: Option<QuickSource>,
/// The display profile (id) the image is encoded for; `None`: sRGB (see
/// [`crate::display::present`]).
pub display: Option<u64>,
}
impl RenderJob {
@@ -734,6 +739,21 @@ impl RenderJob {
self
}
/// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready
/// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB.
/// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are:
/// [`crate::display::present`] converts their sRGB results.
pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self {
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
return self;
}
let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93);
self.key ^= k(&self.display) ^ k(&display);
self.request.display = display.as_ref().map(|d| d.space);
self.display = display;
self
}
pub fn run(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get_at(self.cache_generation, *key)
@@ -749,10 +769,18 @@ impl RenderJob {
rendered: Ok(Rendered { image, histogram, deep: None }),
loaded: None,
quick: None,
display: None,
};
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load_source() {
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string());
let source = match closing() {
Some(e) => Err(e),
None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)),
};
match source {
Ok(source) => {
// the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for
let src = &source.image_for(self.settings.denoise_amount());
@@ -760,14 +788,30 @@ impl RenderJob {
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
let gpu = self.cache.is_none();
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
// for a display profile: the image goes to the display's values, the histogram
// and the cached view preview stay sRGB
let mut srgb = None;
let display = match (&self.display, self.request.display) {
(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 {
rendered.histogram = h;
}
srgb = s;
Some(d.id())
}
_ => None,
};
if let Some((cache, key)) = &self.cache {
cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
}
if let Some((cache, key)) = &self.view_cache
&& self.request.quality == Quality::Full
&& (display.is_none() || srgb.is_some())
{
cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone()));
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
}
RenderResult {
request_id: self.request_id,
@@ -778,6 +822,7 @@ impl RenderJob {
rendered: Ok(rendered),
loaded: (!was_loaded).then_some(source),
quick: None,
display,
}
}
Err(e) => RenderResult {
@@ -789,6 +834,7 @@ impl RenderJob {
rendered: Err(e),
loaded: None,
quick: None,
display: None,
},
}
}
@@ -853,6 +899,7 @@ impl QuickJob {
rendered,
loaded: None,
quick,
display: None,
};
let rendered = |image: Rgba8| {
let histogram = Histogram::of_srgb8(&image);
@@ -972,6 +1019,7 @@ impl crate::Session {
cache,
stages: None,
view_cache: None,
display: None,
})
}
@@ -1022,6 +1070,7 @@ impl crate::Session {
cache: Some((self.media.rendered.clone(), ck)),
stages: None,
view_cache: None,
display: None,
})
}
@@ -1067,6 +1116,7 @@ impl crate::Session {
cache: Some((self.media.rendered.clone(), ck)),
stages: None,
view_cache: None,
display: None,
})
}
+19 -1
View File
@@ -18,7 +18,9 @@ use serde::Serialize;
/// - a quarter for the working memory of decodes in flight ([`work_gate`]);
/// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle).
///
/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default:
/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). An export
/// batch may, in addition, hold up to twice the budget in photos rendering side by side
/// ([`export_gate`]): transient memory, given back when the batch ends. Default:
/// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it).
pub fn default_budget() -> usize {
if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::<usize>().ok()).filter(|m| *m >= 64) {
@@ -83,6 +85,9 @@ fn apply(b: usize) {
if let Some(g) = GATE.get() {
g.set_limit(b / 4);
}
if let Some(g) = EXPORT_GATE.get() {
g.set_limit(export_share(b));
}
lightcraft_gpu::set_pool_limit((b / 8) as u64);
}
@@ -174,6 +179,19 @@ pub fn work_gate() -> &'static WorkGate {
static GATE: OnceLock<WorkGate> = OnceLock::new();
/// The process-wide gate for the photos of an export batch rendering side by side
/// ([`crate::export::run_batch`]; limit: twice the budget). Separate from [`work_gate`], which the
/// decodes inside each export take: one gate held across the other's acquire could deadlock.
pub fn export_gate() -> &'static WorkGate {
EXPORT_GATE.get_or_init(|| WorkGate::new(export_share(budget())))
}
fn export_share(budget: usize) -> usize {
budget.saturating_mul(2)
}
static EXPORT_GATE: OnceLock<WorkGate> = OnceLock::new();
/// Entries and bytes held by one cache.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Usage {
+77 -5
View File
@@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use crate::activity::{Activity, Cancel, TaskHandle, Unit};
use lightcraft_denoise::known::Download;
/// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it
@@ -43,15 +45,20 @@ pub enum State {
struct Shared {
state: Mutex<State>,
cancel: AtomicBool,
cancel: Arc<AtomicBool>,
/// The download's row in the activity stack.
task: Option<TaskHandle>,
}
impl Shared {
fn new(total: u64) -> Self {
Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) }
Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: Arc::new(AtomicBool::new(false)), task: None }
}
fn set(&self, s: State) {
if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) {
task.progress(*bytes, *total);
}
*self.state.lock().unwrap_or_else(PoisonError::into_inner) = s;
}
@@ -69,18 +76,27 @@ pub struct Downloads {
impl Downloads {
/// Start fetching `spec` into `<models dir>/.downloads/`. Different models can download side by side; the same
/// one is started once.
pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> {
/// The activity stack shows it (as `name`) until the thread ends; its ✕ stops it like [`Downloads::discard`].
pub fn start(&mut self, spec: Download, models_dir: &Path, activity: &Activity, name: &str) -> Result<(), String> {
if self.jobs.get(&spec.id).is_some_and(|j| matches!(j.get(), State::Running { .. })) {
return Err("that model is already downloading".into());
}
let staging = models_dir.join(STAGING);
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?;
let shared = Arc::new(Shared::new(spec.size_bytes));
let cancel = Arc::new(AtomicBool::new(false));
let task = activity.start("download", "Downloading model", Cancel::Flag(cancel.clone()));
task.set_unit(Unit::Bytes);
task.detail(name);
task.progress(0, spec.size_bytes);
let total = spec.size_bytes;
let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) });
let worker = shared.clone();
let id = spec.id.clone();
std::thread::Builder::new()
.name("denoise-model-download".into())
.spawn(move || {
// the row goes when the thread ends, however it ends
let _task = task;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker)));
if outcome.is_err() {
worker.set(State::Failed("the download stopped unexpectedly".into()));
@@ -218,7 +234,7 @@ mod tests {
use std::net::TcpListener;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-face-dl-{name}-{}", std::process::id()));
let d = std::env::temp_dir().join(format!("lc-denoise-dl-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
@@ -254,6 +270,62 @@ mod tests {
base
}
/// A local server that sends `body` a few kilobytes at a time: a download that keeps running for a while.
fn serve_slowly(body: Vec<u8>) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/m", l.local_addr().unwrap());
std::thread::spawn(move || {
for mut s in l.incoming().flatten() {
let mut r = BufReader::new(s.try_clone().unwrap());
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
break;
}
}
let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());
for chunk in body.chunks(4096) {
if s.write_all(chunk).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
}
});
base
}
/// Poll until `done` holds (or fail after 10 s).
fn wait(what: &str, done: impl Fn() -> bool) {
let t = std::time::Instant::now();
while !done() {
assert!(t.elapsed() < Duration::from_secs(10), "{what}");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_running_download_shows_in_activity_and_cancels() {
use crate::activity::{Activity, Unit};
let dir = scratch("activity");
let bytes: Vec<u8> = (0..400_000u32).map(|i| (i % 251) as u8).collect();
let mut spec = spec_for(&bytes);
spec.url = format!("{}/{}", serve_slowly(bytes), spec.file_name);
let activity = Activity::default();
let mut d = Downloads::default();
d.start(spec, &dir, &activity, "Test Model").unwrap();
let rows = activity.list();
assert_eq!(rows.len(), 1);
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "Test Model"));
assert_eq!((rows[0].unit, rows[0].total), (Unit::Bytes, 400_000));
assert!(rows[0].cancellable);
activity.cancel(rows[0].id).unwrap();
wait("the download never stopped", || !d.running());
assert_eq!(d.jobs["test-model"].get(), State::Cancelled);
wait("the row stayed", || activity.list().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fetches_checks_and_stages_a_file() {
let dir = scratch("ok");
+1 -1
View File
@@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
}
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT);
if from_photo && let Some(o) = settings.as_object_mut() {
// a photo's own framing and absolute white balance don't belong in a look
o.remove("crop");
+19 -4
View File
@@ -8,6 +8,8 @@ use std::sync::{Arc, Mutex};
use lightcraft_segment::fetch::{self, Options, Progress};
use crate::activity::{Activity, Cancel, Unit};
/// What the session sees of a download.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct Status {
@@ -31,7 +33,7 @@ struct Text {
#[derive(Default)]
struct Shared {
running: AtomicBool,
cancel: AtomicBool,
cancel: Arc<AtomicBool>,
finished: AtomicBool,
done: AtomicU64,
total: AtomicU64,
@@ -92,9 +94,17 @@ impl Downloader {
running
}
/// Start downloading `files` from `mirrors` into `dir` on a background thread. False when
/// one is already running.
pub fn start(&self, files: &'static [fetch::FileSpec], mirrors: Vec<String>, dir: PathBuf, opts: Options) -> Result<bool, String> {
/// Start downloading `files` from `mirrors` into `dir` on a background thread, shown in the activity stack as
/// `name` (its ✕ cancels like [`Downloader::cancel`]). False when one is already running.
pub fn start(
&self,
files: &'static [fetch::FileSpec],
mirrors: Vec<String>,
dir: PathBuf,
opts: Options,
activity: &Activity,
name: &str,
) -> Result<bool, String> {
let s = self.shared.clone();
if s.running.swap(true, Ordering::SeqCst) {
return Ok(false);
@@ -106,6 +116,10 @@ impl Downloader {
// nothing else runs while `running` was false
*lock(&s.text) = Text::default();
*lock(&self.last) = Text::default();
let task = activity.start("download", "Downloading model", Cancel::Flag(s.cancel.clone()));
task.set_unit(Unit::Bytes);
task.detail(name);
task.progress(0, s.total.load(Ordering::SeqCst));
let guard = Running(s.clone());
let spawned = std::thread::Builder::new().name("sam3-download".into()).spawn(move || {
let s = guard.0.clone();
@@ -113,6 +127,7 @@ impl Downloader {
let mut on_progress = |p: &Progress| {
s.done.store(p.done, Ordering::SeqCst);
s.total.store(p.total, Ordering::SeqCst);
task.progress(p.done, p.total);
if p.file != last_file {
last_file.clone_from(&p.file);
lock(&s.text).file.clone_from(&p.file);
+3 -3
View File
@@ -259,7 +259,7 @@ impl Segmenter {
/// Start downloading the model (on a background thread; see `segment.model.download`).
/// `Ok(false)` when it is installed or downloading already.
pub fn start_download(&self) -> Result<bool, String> {
pub fn start_download(&self, activity: &crate::activity::Activity) -> Result<bool, String> {
if !Self::AVAILABLE {
return Err("AI masks are not available in this build".into());
}
@@ -273,11 +273,11 @@ impl Segmenter {
if mirrors.is_empty() {
return Err(lightcraft_segment::fetch::no_mirrors_message());
}
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options())
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options(), activity, "SAM 3")
}
#[cfg(not(feature = "sam"))]
{
let _ = dir;
let _ = (dir, activity);
Ok(false)
}
}
+44 -10
View File
@@ -114,8 +114,10 @@ pub enum DevelopPatch {
Partial(Value),
}
/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`.
pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, String> {
/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read
/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the
/// as-shot white for everything developed relative to it).
pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result<SidecarData, String> {
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
let m = &d.metadata;
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
@@ -133,7 +135,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
country: m.country.clone(),
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.clone(),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
keywords: (!m.keywords.is_empty() || !m.hierarchical_keywords.is_empty())
.then(|| lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords)),
// A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that
// knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's.
// One without it (most writers, LightCraft's own included, which keeps another app's
@@ -162,7 +165,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
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), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0))
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
}
None => None,
};
@@ -445,6 +448,18 @@ impl Session {
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
/// sidecar — a raw first, else the first by file name. The others use Full naming
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
/// (per photo that is a pass over the whole catalog).
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
ids.iter()
.map(|id| match (self.catalog.photo(*id), &owners) {
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
_ => self.xmp.naming,
})
.collect()
}
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
match self.catalog.photo(id) {
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
@@ -475,9 +490,15 @@ impl Session {
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, p.kind == MediaKind::Raw)
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
.resolve_label(&self.catalog);
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)))
}
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
/// thread) to photo `id`: the sidecar wins. No file is read.
pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result<Op> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let sc = sc.resolve_label(&self.catalog);
let mut q = (**p).clone();
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
let mut ops = vec![
@@ -492,7 +513,7 @@ impl Session {
if develop_changed {
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
}
Ok(Some((Op::Batch { ops }, from)))
Ok(Op::Batch { ops })
}
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
@@ -515,6 +536,19 @@ impl Session {
#[cfg(test)]
mod tests {
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
/// gives the photo its keywords as paths.
#[test]
fn sidecar_keywords_keep_their_hierarchy() {
let m = lightcraft_meta::Metadata {
keywords: vec!["Lisbon".into(), "Places".into()],
hierarchical_keywords: vec!["Places|Lisbon".into()],
..Default::default()
};
let sc = super::parse_sidecar(&lightcraft_meta::write_xmp(&m, None), crate::crs::Target::Rendered).unwrap();
assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()]));
}
use super::*;
use lightcraft_catalog::Meta;
@@ -553,7 +587,7 @@ mod tests {
fn packet_roundtrip_restores_everything() {
let p = photo();
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
let sc = parse_sidecar(&x, false).unwrap();
let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap();
assert_eq!(sc.rating, Some(4));
assert_eq!(sc.flag, Some(Flag::Pick));
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
@@ -572,7 +606,7 @@ mod tests {
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
let sc = parse_sidecar(x, true).unwrap();
let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap();
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
let mut p = photo();
let before = p.develop.clone();
+55 -1
View File
@@ -24,6 +24,10 @@ pub fn file_name(p: &Photo) -> String {
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
const HEADER_MAX: u64 = 4096;
/// The header field that records which version of the camera-look fit (`camera_preview::LOOK_VERSION`)
/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve.
const LOOK_FIELD: &str = "look_version";
/// 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
@@ -53,7 +57,7 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
)
.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});
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
@@ -187,6 +191,56 @@ pub fn is_valid(path: &Path) -> bool {
f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
}
/// The fit version a header records: a missing, negative, fractional or otherwise malformed value
/// counts as 0 (older than any real version).
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.
pub fn is_stale(path: &Path) -> bool {
use std::io::Read;
if !is_valid(path) {
return false;
}
let Ok(f) = std::fs::File::open(path) else { return false };
let mut head = Vec::with_capacity(1024);
if f.take(HEADER_MAX).read_to_end(&mut head).is_err() {
return false;
}
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
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))
}
/// The marker file in a smart previews folder: the `LOOK_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.
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 };
let mut text = String::new();
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))
}
/// Record that the proxies in `dir` were checked at this build's camera-look fit 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()))
}
/// Load the proxy at `path`.
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
+702
View File
@@ -0,0 +1,702 @@
//! Synchronize Folder: bring a folder of the library up to date with what is on disk, as
//! Lightroom Classic's Synchronize Folder does.
//!
//! A [scan](scan_with) compares the folder (and the folders inside it) with the library and finds
//! three kinds of change, touching nothing:
//!
//! - **new** files: supported files the library doesn't have, by path or by content (the import
//! scan's duplicate rules, [`crate::import::scan_with`]);
//! - **missing** photos: library photos in the folder whose file is gone (the Missing Photos rule,
//! [`crate::cmd::missing::checked_path`]);
//! - **metadata** updates: photos whose XMP sidecar file was saved after the photo came into the
//! library or was last edited here, and says something the library doesn't (rating, flag,
//! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as
//! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at.
//!
//! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the
//! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as
//! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is
//! read on a worker thread and only the catalog changes happen on the UI thread — with what every
//! command does after them there: the library's durable save and, with XMP auto-write on, the
//! sidecars of the photos whose metadata was read.
//!
//! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a
//! worker thread: a folder on a sleeping network share must never stall a frame.
//!
//! Limits of telling an update from a sidecar that is merely older:
//! - a sidecar the library itself wrote (auto-write on) agrees with it and is never an update;
//! - the library records when a photo came in and when its develop settings last changed, not
//! when its rating, flag, label or metadata did: a sidecar another app saved, followed by such
//! a change made here (with auto-write off), is listed as an update, and reading it puts the
//! older value back. Reading updates is therefore never the default;
//! - times are compared as the clock the session runs with; headless sessions (CLI, MCP) start
//! with a fixed clock, so their own times say little;
//! - a sidecar is found as Read Metadata from File finds it, including the other naming
//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`).
//!
//! The walk for new files stops at the import's limits on depth and entries (logged), and is
//! not cancelled midway.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
use serde::Serialize;
use crate::import::{ImportCandidate, ImportMode, ImportOptions, ScanInput, ScanProgress};
use crate::media::ProbeInfo;
use crate::sidecar::SidecarNaming;
use crate::{EngineError, Result, Session};
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
/// whole scan) and the library's photos checked.
#[derive(Default)]
pub struct SyncProgress {
pub files: ScanProgress,
pub checked: std::sync::atomic::AtomicUsize,
pub to_check: std::sync::atomic::AtomicUsize,
}
impl SyncProgress {
/// (done, of how many), both halves together.
pub fn counts(&self) -> (usize, usize) {
use std::sync::atomic::Ordering::Relaxed;
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
}
}
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
pub struct SyncInput {
folder: String,
scan: ScanInput,
/// The library's photos in the folder, each with how its sidecar is named.
photos: Vec<(Arc<Photo>, SidecarNaming)>,
/// For the colour label names a sidecar may use.
labels: Catalog,
}
/// What changed in a folder since it came into the library.
#[derive(Clone, Debug, Default, Serialize)]
pub struct FolderChanges {
/// The folder, as asked for.
pub path: String,
/// Files to import.
pub new: Vec<ImportCandidate>,
/// Files whose content the library already has (under another path).
pub duplicates: usize,
/// Supported files that couldn't be read.
pub unreadable: Vec<ImportCandidate>,
/// Photos whose file is gone.
pub missing: Vec<FolderPhoto>,
/// Photos whose sidecar has news.
pub metadata: Vec<FolderPhoto>,
/// Photos whose file was renamed or moved within the folder (found by its content).
pub moved: Vec<FolderMoved>,
/// The folder itself is not there (moved or renamed whole, or on a disk that isn't
/// connected): nothing else is reported, rather than every photo as missing.
pub offline: bool,
/// The probes of the new files, kept for the import that follows.
#[serde(skip)]
probes: HashMap<String, ProbeInfo>,
/// The folder's photos as the scan saw them: the scan is current while they are the same
/// records (any change to one replaces it in the catalog).
#[serde(skip)]
seen: Vec<Arc<Photo>>,
}
/// A photo of the folder and its file.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FolderPhoto {
pub id: u64,
pub path: String,
}
/// A photo whose file is now somewhere else in the folder.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FolderMoved {
pub id: u64,
pub from: String,
pub to: String,
}
/// What [`synchronize`] does with the changes found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SyncChoice {
pub import_new: bool,
pub relink_moved: bool,
pub remove_missing: bool,
pub read_metadata: bool,
}
impl Default for SyncChoice {
/// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and
/// photos whose file moved follow it (nothing is lost, and undo puts them back).
fn default() -> Self {
SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false }
}
}
/// What [`synchronize`] did.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SyncReport {
pub imported: usize,
pub relinked: usize,
pub removed: usize,
pub read: usize,
/// (path or photo, why) for what couldn't be done.
pub failed: Vec<(String, String)>,
}
fn invalid(msg: impl Into<String>) -> EngineError {
EngineError::Other(msg.into())
}
impl SyncInput {
/// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A
/// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk`
/// (as for Remove Disk from Library: its missing photos can be a whole disk's).
pub fn new(s: &mut Session, path: &str, disk: bool) -> Result<SyncInput> {
let path = path.trim();
if path.is_empty() {
return Err(invalid("missing `path`"));
}
if lightcraft_catalog::folders::is_startup_disk(path) {
return Err(invalid("choose a folder, not the whole startup disk"));
}
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
let ids = s.catalog.query(&f, &Sort::default());
if ids.is_empty() {
return Err(invalid(format!("{path}: no photo in the library was imported from it")));
}
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
}
let namings = s.sidecar_namings(&ids);
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
}
}
/// Find what changed in the folder (see the module docs). Reads the disk, never the session.
///
/// Both halves wait on the disk (a network share answers each file slowly), so they run at once:
/// the look for new files (which probes them on its own threads) beside the checks of the
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
/// is set.
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
let SyncInput { folder, scan, photos, labels } = input;
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
}
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
#[cfg(not(target_arch = "wasm32"))]
let (out, checks) = std::thread::scope(|sc| {
let photos = &photos;
let labels = &labels;
let checks = sc.spawn(move || check_photos(photos, labels, progress));
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
// (a check that panicked counts as nothing found; the scan still reports the rest)
(out, checks.join().unwrap_or_default())
});
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
for ((p, _), check) in photos.iter().zip(checks) {
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
let found = FolderPhoto { id: p.id.0, path: file.to_string() };
match check {
Check::Missing => changes.missing.push(found),
Check::News => changes.metadata.push(found),
Check::Nothing => {}
}
}
let moved_from = match_moved(&changes.missing, &out.candidates, &out.probes, &labels);
for c in out.candidates {
if moved_from.values().any(|to| *to == c.path) {
continue;
}
let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted);
match (c.duplicate.as_deref(), &c.error) {
// a file only looked at in Local is not in the library yet: it is new
(Some("path"), _) if browsed => changes.new.push(ImportCandidate { duplicate: None, existing: None, ..c }),
(Some("path"), _) => {}
(Some(_), _) => changes.duplicates += 1,
(None, Some(_)) => changes.unreadable.push(c),
(None, None) => changes.new.push(c),
}
}
let missing = std::mem::take(&mut changes.missing);
for m in missing {
match moved_from.get(&m.path) {
Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }),
None => changes.missing.push(m),
}
}
changes.probes = out.probes;
changes
}
/// Whether a photo is the same as far as synchronizing goes: its file, its place in the library
/// and what a sidecar is compared with. Face and pet regions found in the background meanwhile
/// don't make a scan stale.
fn same_for_sync(a: &Arc<Photo>, b: &Arc<Photo>) -> bool {
if Arc::ptr_eq(a, b) {
return true;
}
let meta = |p: &Photo| lightcraft_catalog::Meta { regions: Vec::new(), ..p.meta.clone() };
(&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size, a.rating, a.flag, a.label, &a.captured, &a.edited, &a.develop)
== (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size, b.rating, b.flag, b.label, &b.captured, &b.edited, &b.develop)
&& meta(a) == meta(b)
}
/// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup<id>`.
fn base_hash(h: &str) -> &str {
h.split(':').next().unwrap_or(h)
}
/// Missing photos' files found elsewhere in the folder (old path → new path): the file was
/// renamed or moved, and the photo (with its edits) follows it rather than going missing.
///
/// By content first: each missing file's hash claims one file with that content (two missing
/// files with the same content claim two). A photo with no hash on record (taken over from a
/// Lightroom catalog) is matched by file size and dimensions, and only when exactly one missing
/// file and exactly one unclaimed file of the folder agree on them.
fn match_moved(
missing: &[FolderPhoto],
candidates: &[ImportCandidate],
probes: &HashMap<String, ProbeInfo>,
labels: &Catalog,
) -> HashMap<String, String> {
// distinct missing files (a photo's virtual copies share its file), with what is known of them
let mut files: Vec<(&str, Option<&Photo>)> = Vec::new();
for m in missing {
if !files.iter().any(|(p, _)| *p == m.path) {
files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p)));
}
}
let mut by_hash: HashMap<&str, std::collections::VecDeque<&str>> = HashMap::new();
for (path, p) in &files {
if let Some(h) = p.and_then(|p| p.content_hash.as_deref()) {
by_hash.entry(base_hash(h)).or_default().push_back(path);
}
}
let usable = |c: &&ImportCandidate| c.error.is_none() && c.duplicate.as_deref() != Some("path");
let mut moved: HashMap<String, String> = HashMap::new();
for c in candidates.iter().filter(usable) {
let hash = probes.get(&c.path).and_then(|i| i.content_hash.as_deref());
if let Some(from) = hash.and_then(|h| by_hash.get_mut(base_hash(h))).and_then(std::collections::VecDeque::pop_front) {
moved.insert(from.to_string(), c.path.clone());
}
}
let key = |size: u64, w: u32, h: u32| (size > 0 && w > 0 && h > 0).then_some((size, w, h));
let mut hashless: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
for (path, p) in &files {
if let Some(p) = p.filter(|p| p.content_hash.is_none())
&& let Some(k) = key(p.file_size, p.width, p.height)
{
hashless.entry(k).or_default().push(path);
}
}
let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) {
if let Some(k) = key(c.file_size, c.width, c.height) {
found.entry(k).or_default().push(c.path.as_str());
}
}
for (k, from) in hashless {
if let ([from], Some([to])) = (from.as_slice(), found.get(&k).map(Vec::as_slice)) {
moved.insert(from.to_string(), to.to_string());
}
}
moved
}
/// What checking one photo of the folder found.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Check {
#[default]
Nothing,
Missing,
News,
}
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
if !Path::new(file).exists() {
Check::Missing
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
Check::News
} else {
Check::Nothing
}
}
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
use std::sync::atomic::Ordering::Relaxed;
progress.to_check.store(photos.len(), Relaxed);
let cancel = &progress.files.cancel;
let check = |p: &Photo, naming: SidecarNaming| {
let c = check_photo(p, naming, labels);
progress.checked.fetch_add(1, Relaxed);
c
};
if cfg!(target_arch = "wasm32") {
return vec![Check::Nothing; photos.len()];
}
#[cfg(not(target_arch = "wasm32"))]
{
let n = crate::import::workers(photos.len());
if n > 1 {
let next = std::sync::atomic::AtomicUsize::new(0);
let found = std::sync::Mutex::new(vec![Check::Nothing; photos.len()]);
std::thread::scope(|sc| {
for _ in 0..n {
sc.spawn(|| {
while !cancel.load(Relaxed) {
let i = next.fetch_add(1, Relaxed);
let Some((p, naming)) = photos.get(i) else { break };
let c = check(p, *naming);
if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
*slot = c;
}
}
});
}
});
return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
}
}
photos.iter().map(|(p, naming)| if cancel.load(Relaxed) { Check::Nothing } else { check(p, *naming) }).collect()
}
/// [`scan_with`] on the calling thread.
pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result<FolderChanges> {
let input = SyncInput::new(s, path, disk)?;
let changes = scan_with(input, &SyncProgress::default());
if changes.offline {
return Err(not_there(&changes.path));
}
Ok(changes)
}
fn not_there(path: &str) -> EngineError {
invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)"))
}
impl Session {
/// The last scan ([`Session::folder_changes`]) if it is of folder `path` and still current:
/// the folder holds the same photos, none of them changed. What the person was shown is what
/// gets done. Used once. (Changes elsewhere in the library don't matter.)
pub fn take_folder_changes(&mut self, path: &str) -> Option<FolderChanges> {
let c = self.folder_changes.take()?;
if lightcraft_catalog::query::folder_key(&c.path) != lightcraft_catalog::query::folder_key(path) {
return None;
}
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
let mut now: Vec<PhotoId> = self.catalog.query(&f, &Sort::default());
let mut then: Vec<PhotoId> = c.seen.iter().map(|p| p.id).collect();
now.sort();
then.sort();
let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| same_for_sync(p, q)));
unchanged.then_some(c)
}
}
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
/// (imported, or edited here) and says something the library doesn't.
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
let Source::File { path } = &p.source else { return false };
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
let Ok(meta) = std::fs::metadata(&file) else { return false };
if meta.len() > MAX_SIDECAR_BYTES {
return false;
}
let saved = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
let Some(saved) = saved.and_then(|s| i64::try_from(s).ok()) else { return false };
let seen = [Some(p.imported.as_str()), p.edited.as_deref()].into_iter().flatten().filter_map(lightcraft_catalog::stacks::iso_seconds).max();
if seen.is_some_and(|t| saved <= t) {
return false;
}
let Ok(text) = std::fs::read_to_string(&file) else { return false };
let Ok(sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false };
let sc = sc.resolve_label(labels);
let mut q = p.clone();
crate::sidecar::merge_into(&mut q, &sc, "");
(q.rating, q.flag, q.label, &q.meta, &q.captured, &q.develop) != (p.rating, p.flag, p.label, &p.meta, &p.captured, &p.develop)
}
/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo
/// whose file is back by now is left alone. Once the import is in, a part that fails is
/// reported in `failed` and the rest is still done (and still one step).
pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<SyncReport> {
let (work, mut commit) = SyncJob::start(s, changes, choice)?;
let mut steps = Vec::new();
work.run(&SyncProgress::default(), |step| {
steps.push(step);
true
});
for step in steps {
commit.apply(s, step);
}
Ok(commit.finish(s))
}
/// Synchronizing in halves, so no file is touched on the UI thread: [`SyncJob::start`] takes what
/// it needs from the session (no file-system call); the [`SyncWork`] does every file read (the
/// import's probes, whether files are still where the scan found them, the sidecars) and hands
/// over [`SyncStep`]s — on a worker thread in the app; [`SyncCommit::apply`] puts each into the
/// catalog and [`SyncCommit::finish`] makes them one undo step.
pub struct SyncJob;
/// The file-system half of a synchronize (see [`SyncJob`]).
pub struct SyncWork {
/// The import of the new files, and the files.
import: Option<(crate::import::ImportJob, Vec<String>)>,
moved: Vec<FolderMoved>,
missing: Vec<FolderPhoto>,
/// (photo, its file, how its sidecar is named)
sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>,
}
/// The catalog half of a synchronize (see [`SyncJob`]).
pub struct SyncCommit {
opts: ImportOptions,
now: String,
/// The library the run was readied for: steps never land in another one (File › Open
/// Library while the work runs), whose photo ids name other photos.
library: Arc<()>,
/// The run's own undo steps so far, and the history's length after the last of them: they
/// are merged only while nothing else came in between (an Undo or an edit made meanwhile
/// stays its own step, and the run's parts stay theirs).
own: usize,
after_last: Option<usize>,
interleaved: bool,
report: SyncReport,
}
/// A piece of work done, for [`SyncCommit::apply`].
pub enum SyncStep {
/// A batch of new files, readied for the catalog.
Import(crate::import::Prepared),
/// Moved photos whose file is still at its new place (photo, new path).
Relink(Vec<(PhotoId, String)>),
/// Missing photos whose file is still gone.
Remove(Vec<PhotoId>),
/// Sidecars read (photo, its file, what the sidecar says or why it couldn't be read).
Sidecars(Vec<(PhotoId, String, std::result::Result<crate::sidecar::SidecarData, String>)>),
}
impl SyncJob {
/// Ready a synchronize of `changes` as `choice` says. Reads no file.
pub fn start(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<(SyncWork, SyncCommit)> {
if changes.offline {
return Err(not_there(&changes.path));
}
let FolderChanges { new, missing, metadata, moved, probes, .. } = changes;
let opts = ImportOptions { mode: ImportMode::Add, ..Default::default() };
let import = if choice.import_new && !new.is_empty() {
s.import_probes = probes;
let job = crate::import::ImportJob::new(s, opts.clone())?;
Some((job, new.into_iter().map(|c| c.path).collect()))
} else {
None
};
let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string());
let sidecars = if choice.read_metadata {
let namings = s.sidecar_namings(&metadata.iter().map(|m| PhotoId(m.id)).collect::<Vec<_>>());
metadata
.into_iter()
.zip(namings)
.filter_map(|(m, naming)| {
let p = s.catalog.photo(PhotoId(m.id))?;
Some((p.id, m.path, naming, crate::crs::Target::for_photo(p)))
})
.collect()
} else {
Vec::new()
};
let work = SyncWork {
import,
moved: if choice.relink_moved { moved } else { Vec::new() },
missing: if choice.remove_missing { missing } else { Vec::new() },
sidecars,
};
let commit = SyncCommit {
opts,
now,
library: Arc::clone(&s.library_identity),
own: 0,
after_last: None,
interleaved: false,
report: SyncReport::default(),
};
Ok((work, commit))
}
}
impl SyncWork {
/// Do the work, handing each step to `send` (`false`: nobody takes them any more — stop).
/// Stops before the next piece once `progress.files.cancel` is set; what was handed over
/// stays handed over.
pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) {
use std::sync::atomic::Ordering::Relaxed;
let cancel = &progress.files.cancel;
let SyncWork { import, moved, missing, sidecars } = self;
let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len();
progress.to_check.store(pieces, Relaxed);
progress.checked.store(0, Relaxed);
let step = |n: usize| {
progress.checked.fetch_add(n, Relaxed);
};
if let Some((mut job, files)) = import {
for chunk in files.chunks(crate::import::batch_size()) {
if cancel.load(Relaxed) {
return;
}
let prepared = job.prepare_files(chunk.to_vec(), cancel);
step(chunk.len());
// (the files are added in place: a batch nobody takes leaves nothing behind on disk)
if !send(SyncStep::Import(prepared)) {
return;
}
}
}
if cancel.load(Relaxed) {
return;
}
// still where the scan found it, and still gone from where the photo says
let checked = moved.len();
let relink: Vec<(PhotoId, String)> =
moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect();
step(checked);
if !relink.is_empty() && !send(SyncStep::Relink(relink)) {
return;
}
if cancel.load(Relaxed) {
return;
}
let gone: Vec<PhotoId> = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect();
step(missing.len());
if !gone.is_empty() && !send(SyncStep::Remove(gone)) {
return;
}
let mut read = Vec::with_capacity(sidecars.len());
for (id, path, naming, target) in sidecars {
if cancel.load(Relaxed) {
break;
}
read.push((id, path.clone(), read_sidecar(&path, naming, target)));
step(1);
}
if !read.is_empty() {
send(SyncStep::Sidecars(read));
}
}
}
/// Read and parse photo file `path`'s XMP sidecar file.
fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result<crate::sidecar::SidecarData, String> {
let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?;
let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len();
if len > MAX_SIDECAR_BYTES {
return Err(format!("{}: too large for an XMP sidecar", file.display()));
}
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))
}
impl SyncCommit {
/// Put a step into the catalog (no file is touched). A part that fails is reported in the
/// end, and the rest is still done.
pub fn apply(&mut self, s: &mut Session, step: SyncStep) {
if !Arc::ptr_eq(&self.library, &s.library_identity) {
self.report.failed.push((String::new(), "another library was opened: the rest of the synchronize was dropped".into()));
return;
}
let commits = s.commits();
if self.after_last.is_some_and(|n| n != s.undo.len()) {
self.interleaved = true;
}
self.apply_step(s, step);
if s.commits() != commits {
self.own += usize::try_from(s.commits().wrapping_sub(commits)).unwrap_or(0);
self.after_last = Some(s.undo.len());
}
}
fn apply_step(&mut self, s: &mut Session, step: SyncStep) {
let report = &mut self.report;
match step {
SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) {
Ok(r) => {
report.imported += r.imported.len();
report.failed.extend(r.failed);
}
Err(e) => report.failed.push((String::new(), e.to_string())),
},
SyncStep::Relink(moved) => {
let ops: Vec<Op> =
moved.iter().filter(|(id, _)| s.catalog.photo(*id).is_some()).map(|(id, to)| crate::cmd::missing::relink_op(*id, to)).collect();
let n = ops.len();
if n > 0 {
match s.commit("Relink Moved Photos", Op::Batch { ops }) {
Ok(()) => report.relinked += n,
Err(e) => report.failed.push((String::new(), e.to_string())),
}
}
}
SyncStep::Remove(gone) => {
let ops: Vec<Op> = gone
.iter()
.filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library()))
.map(|id| Op::SetDeleted { id: *id, deleted: true })
.collect();
let n = ops.len();
if n > 0 {
match s.commit("Remove Missing Photos", Op::Batch { ops }) {
Ok(()) => report.removed += n,
Err(e) => report.failed.push((String::new(), e.to_string())),
}
}
}
SyncStep::Sidecars(read) => {
let mut ops = Vec::new();
for (id, path, sc) in read {
match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) {
Ok(op) => ops.push(op),
Err(e) => report.failed.push((path, e.to_string())),
}
}
let n = ops.len();
if n > 0 {
match s.commit("Read Metadata from File", Op::Batch { ops }) {
Ok(()) => report.read += n,
Err(e) => report.failed.push((String::new(), e.to_string())),
}
}
}
}
}
/// What was done, as one undo step ("Synchronize Folder").
pub fn finish(mut self, s: &mut Session) -> SyncReport {
// a report line once, however many steps came after a library switch
self.report.failed.dedup();
let ours = Arc::ptr_eq(&self.library, &s.library_identity) && !self.interleaved && self.after_last == Some(s.undo.len());
let steps = if ours { self.own.min(s.undo.len()) } else { 0 };
s.merge_undo(steps, "Synchronize Folder");
if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) {
last.label = "Synchronize Folder".into();
}
self.report
}
}
+94 -3
View File
@@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, WbMode};
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF", "ORF"] {
let mut s = demo();
let id = PhotoId(100);
let name = format!("synthetic.{format}");
@@ -113,9 +113,10 @@ fn albums_crud() {
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("album.addPhotos", &json!({"id": a})).unwrap();
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().photos.len(), 24);
assert!(s.execute("album.delete", &json!({"id": f})).is_err(), "non-empty folder");
s.execute("album.delete", &json!({"id": a})).unwrap();
s.execute("album.delete", &json!({"id": f})).unwrap();
assert!(s.catalog.album(lightcraft_catalog::AlbumId(a)).is_none());
assert!(s.catalog.album(lightcraft_catalog::AlbumId(f)).is_none());
assert_eq!(s.catalog.photos().count(), 24, "album deletion keeps the photos");
}
/// Given an album and a folder, when something is created inside each, then only the folder
@@ -198,6 +199,60 @@ fn deleting_explicit_targets_keeps_the_surviving_selection() {
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
}
#[test]
fn permanently_deleting_photos_selects_a_neighbour_in_recently_deleted() {
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
for at_end in [false, true] {
let mut s = demo();
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap();
s.execute("library.sort", &params).unwrap();
s.execute("library.filter", &json!({"rating": 2})).unwrap();
let visible = s.visible_cloned();
assert!(visible.len() > 5);
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
let deleted = &visible[at..at + 2];
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
let snapshot = s.catalog.to_snapshot();
let undo_len = s.undo.len();
let r = s.execute("photo.deletePermanently", &json!({})).unwrap();
assert_eq!(r["deleted"], 2);
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
assert!(deleted.iter().all(|id| s.catalog.photo(*id).is_none()));
assert_eq!(s.undo.len(), undo_len + 1);
let removed = s.catalog.to_snapshot();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.to_snapshot(), snapshot);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.catalog.to_snapshot(), removed);
}
}
let mut s = demo();
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("photo.deletePermanently", &json!({})).unwrap();
assert!(s.visible().is_empty());
assert_eq!(s.selection, Selection::default());
}
#[test]
fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
let mut s = demo();
let visible = s.visible_cloned();
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
let before = s.selection.clone();
s.execute("photo.deletePermanently", &json!({"ids": [visible[3]]})).unwrap();
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
s.execute("photo.deletePermanently", &json!({"ids": [visible[9]]})).unwrap();
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
let before = s.selection.clone();
let snapshot = s.catalog.to_snapshot();
assert!(s.execute("photo.deletePermanently", &json!({"ids": [999_999]})).is_err());
assert_eq!(s.selection, before, "a refused deletion doesn't change selection");
assert_eq!(s.catalog.to_snapshot(), snapshot);
}
#[test]
fn masks_crop_and_render() {
let mut s = demo();
@@ -666,6 +721,42 @@ fn build_previews_fills_the_cache() {
assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err());
}
/// Wait (at most a minute) until no background task is listed.
fn wait_activity_empty(s: &Session) {
let t0 = std::time::Instant::now();
while !s.activity.list().is_empty() {
assert!(t0.elapsed().as_secs() < 60, "tasks still listed: {:?}", s.activity.list());
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Issue #345: a background preview build is a row in the activity stack while it runs.
#[test]
fn preview_build_shows_in_activity_until_it_finishes() {
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().take(4).map(|p| p.id.0).collect();
let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap();
let tasks = s.activity.list();
assert_eq!(tasks.first().map(|t| (t.kind, t.total, t.label.as_str())), Some(("previews", 4, "Building previews")), "{tasks:?} {r}");
assert!(tasks[0].cancellable);
wait_activity_empty(&s);
assert_eq!(s.execute("library.previewProgress", &json!({})).unwrap()["running"], false);
}
/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`.
#[test]
fn activity_cancel_stops_a_preview_build() {
let mut s = demo();
let ids: Vec<u64> = s.catalog.photos().map(|p| p.id.0).collect();
s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap();
let id = s.activity.list().first().map(|t| t.id).expect("a previews row");
s.execute("activity.cancel", &json!({"id": id})).unwrap();
wait_activity_empty(&s);
let p = s.execute("library.previewProgress", &json!({})).unwrap();
assert_eq!(p["cancelled"], true, "{p}");
assert!(p["done"].as_u64() < p["total"].as_u64(), "stopped part-way: {p}");
}
/// Colour range: a click samples the colour under it, so the mask selects that colour (white
/// in the mask view) and not a different one; ⇧ adds samples, up to five.
#[test]
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more