Compare commits

...
Author SHA1 Message Date
bflatastic 377fbedbb5 Batch 34: cargo fmt; one tracker row per id 2026-10-13 12:58:59 +07:00
bflatastic 88f1330483 Merge PR #1283 2026-10-13 12:58:44 +07:00
bflatastic 1bbe92214c Merge PR #1386
# Conflicts:
#	docs/parity-checklist.md
#	docs/target-app-parity.md
#	docs/whats-new.md
2026-10-13 12:58:44 +07:00
bflatastic acb4488f18 Merge PR #1208
# Conflicts:
#	crates/engine/src/tests_export.rs
#	docs/file-format-parity.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
2026-10-13 12:58:43 +07:00
bflatastic 5830304692 Batch 34: one tracker row per id, parity summary refreshed 2026-10-13 12:58:31 +07:00
bflatastic 63a123236f Batch 34: RENDER_CACHE_VERSION 63 (A7 III distortion #1384, DNG black levels per plane #1069); cargo fmt 2026-10-13 12:42:54 +07:00
grlessa 75feedcae0 M2.3: normalize DNG black levels independently per sample plane
Share phase-aware scales across full and binned development, guard empty
and non-finite ranges, invalidate old renders, and add synthetic/property coverage.

Co-authored-by: Codex <[email protected]>
(cherry picked from commit e211776001)
2026-10-13 12:41:29 +07:00
grlessa 859d9492f9 M2.3: include Canon raws in reproducible camera calibration
Discover CR2/CR3 inputs and merge parallel per-file pools in sorted input order.
Cover discovery, malformed input reporting and reproducible fitting with CLI tests.

Co-authored-by: Codex <[email protected]>
(cherry picked from commit 66b62124bf)
2026-10-13 12:41:27 +07:00
grlessa aedd3cbbb8 M3.2: reject stale selections and disclose approximate masks
Tie background results to their rendered source, borrow cached mattes for the
approximation notice, and cover provenance and matte access with regressions.

Co-authored-by: Codex <[email protected]>
(cherry picked from commit 9922a180f5)
2026-10-13 12:41:24 +07:00
haranishi 1d02ae775a chore: 不具合・要望・性能問題のIssueフォームを追加 #648
(cherry picked from commit af5685c639)
2026-10-13 12:41:23 +07:00
haranishi bc2c44676f fix: 選択済みのPick旗を塗りつぶして表示 #1013
(cherry picked from commit 72f65288d2)
2026-10-13 12:41:22 +07:00
haranishi 328bdabe66 fix: 切り抜きの初期比率をOriginalでロック #991
(cherry picked from commit 6ff9b95fb9)
2026-10-13 12:41:18 +07:00
haranishi 7d331761de fix: Before側クリックのズーム位置を正しく計算 #1035
(cherry picked from commit e9178ec2fe)
2026-10-13 12:41:14 +07:00
bflatastic 951e59f7cc Merge PR #1113 2026-10-13 12:41:08 +07:00
bflatastic 92093461e0 Merge PR #1384
# Conflicts:
#	docs/whats-new.md
2026-10-13 12:41:08 +07:00
bflatastic 2020ad1df4 Merge PR #1040
# Conflicts:
#	ROADMAP.md
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/target-app-parity.md
2026-10-13 12:40:42 +07:00
bflatastic 923a620d5a Merge PR #1409 2026-10-13 12:40:37 +07:00
bflatastic 97d38486b7 Merge PR #999
# Conflicts:
#	crates/pipeline/src/spots.rs
#	crates/ui-egui/src/tests_panels.rs
#	docs/parity-checklist.md
#	docs/ui-parity.md
#	docs/whats-new.md
2026-10-13 12:40:35 +07:00
bflatastic 395df0429a Batch 34: #1165's discovery test spells paths with the host's separator 2026-10-13 12:40:07 +07:00
bflatastic d18c8454b1 Batch 34: CI checkouts don't keep credentials (#1148); drop a local-run note from the catalog import doc (#842); cargo fmt 2026-10-13 12:30:25 +07:00
bflatastic ca5292f883 Merge PR #1165
# Conflicts:
#	crates/mcp/src/headless.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/raw-parity.md
2026-10-13 12:29:12 +07:00
bflatastic e6d4ea7c60 Merge PR #1223
# Conflicts:
#	crates/catalog/src/lock.rs
#	docs/parity-checklist.md
2026-10-13 12:29:10 +07:00
bflatastic 2f5ba5e69a Merge PR #1148
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/gpu-pipeline.md
#	docs/target-app-parity.md
2026-10-13 12:28:57 +07:00
bflatastic a92066c47c Merge PR #842
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-13 12:28:56 +07:00
bflatastic ac40fa3882 Merge PR #1195
# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-13 12:28:52 +07:00
bflatastic 5f81dfcdde Merge PR #1014
# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/gaps.md
#	docs/parity-checklist.md
2026-10-13 12:28:48 +07:00
bflatastic 9b5f38039d Merge PR #1002
# Conflicts:
#	crates/pipeline/src/spots.rs
2026-10-13 12:28:46 +07:00
bflatastic 789eb13f8a Merge PR #1056 2026-10-13 12:28:43 +07:00
bflatastic 6690034b10 Merge PR #1067
# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/gaps.md
2026-10-13 12:28:42 +07:00
bflatastic c0818c9dd0 Merge PR #1304 2026-10-13 12:28:39 +07:00
bflatastic cc0df6474a Merge PR #1313 2026-10-13 12:28:38 +07:00
bflatastic 72cc6e7fdf Merge PR #1399 2026-10-13 12:28:38 +07:00
bflatastic d84b295e23 Merge PR #1124
# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 12:28:38 +07:00
bflatastic e0c1c415e5 Merge PR #1128
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 12:28:36 +07:00
bflatastic 1ea573ebfd Merge PR #1152
# Conflicts:
#	docs/parity-checklist.md
2026-10-13 12:28:33 +07:00
bflatastic 3eb3722e2c Merge PR #1147 2026-10-13 12:28:32 +07:00
bflatastic 8e04b5089a Merge PR #1385 2026-10-13 12:28:32 +07:00
bflatastic b7714cfc68 Merge PR #1289 2026-10-13 12:28:31 +07:00
bflatastic 5f44b7f4b3 Merge PR #1287 2026-10-13 12:28:31 +07:00
bflatastic 639ff976e2 Merge PR #1117
# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-13 12:28:31 +07:00
Alex ChambersandCodex a8f0db17ea LR-IMP-FORMATS: discover Sony SR2 and SRF files in folders
Reuse the engine's import extensions in MCP and CLI discovery. Add
recursive, case-insensitive discovery tests and record that DSC-F828
SRF import remains preview-only.

Co-Authored-By: Codex <[email protected]>
2026-10-13 06:29:06 +02:00
Marco Arnulfo 5b30b47769 Library on an SMB share: fall back to a plain fsync where macOS refuses the full flush (#1407) 2026-10-13 05:19:22 +02:00
Marco Arnulfo c884fc3bcc Library on an SMB share: the catalog files are written where macOS can't fully flush them (fixes #1407) 2026-10-13 05:07:30 +02:00
hasan-sdaif 87c82993e7 Add Arabic README translation (README.ar.md) 2026-10-13 01:52:34 +00:00
Nathaniel LedfordandClaude Opus 5.5 5895918791 Issue #1184: a pull request template
.github/PULL_REQUEST_TEMPLATE.md asks for what the people merging ask for
most in their comments on past pull requests: the issue it fixes, overlap
with other open pull requests, whether existing edits render differently,
the shared version numbers it takes, where decoder and camera data came
from, and new dependencies or CI changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:08:52 -04:00
Claude 91ae057cac Fork only: drop the Windows installer workflow before the upstream PR
Reverts ed68b2e; the installer it built stays in alvarohn/lightcraft's releases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 00:01:47 +00:00
Álvaro HNandClaude Opus 5.5 ae8c9281d1 contributors: add my name (Álvaro HN)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 00:01:32 +00:00
Claude 60c3efdfc3 LR-EDIT-OPTICS-PROFILE: keep the distortion check with the other Python tools
Moves it to tools/ with a pinned requirements file, as tools/spectral_camera_table.py does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-13 00:01:21 +00:00
Claude ed68b2e685 Fork only: build and publish the Windows x64 installer of this branch
Not for upstream. Same steps as release.yml's Windows job, unsigned, published as a pre-release
of alvarohn/lightcraft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-12 23:53:59 +00:00
Claude 90fa467d7e LR-EDIT-OPTICS-PROFILE: enable Sony ILCE-7M3 embedded distortion correction
The A7 III's raw-IFD 0x7037 tables were refused until their geometry was checked. Measured against
the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto) on 27 RAW+JPEG pairs from
one body with the FE 16-35 F4 ZA, FE 28-70 and FE 50 F1.8 (strong barrel to strong pincushion,
compressed and uncompressed, TV and monitor checkerboards): corrected board corners sit 0.06-0.44 px
(median, 6000 px frame) from the JPEG's, against 0.8-26 px uncorrected; LightCraft applies the
camera's amount of correction within 2 %; corrected rows and columns are as straight as the JPEG's.
Sony's crop and the default crop agree on this body, so the warp is centred on the image.

A unit test pins the warp from three of these tables to the camera's fitted geometry; the model
boundary tests now accept ILCE-7M3 and still refuse its neighbours. RENDER_CACHE_VERSION is bumped.
The measurement script handles screen moiré (X-corner check, duplicate corners, consensus lattice
vectors). The corpus test's two ILCE-7M3 samples now expect one warp; raw.pixls.us is not reachable
from this environment, so that test was skipped here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-12 23:53:59 +00:00
Alex ChambersandClaude Opus 5.5 78350843e4 LR-META-XMP: settings JSON sent back again or re-rounded keeps the Upright correction in the photo's frame (#205)
Fifth review round on 725eca27:

- develop.merge: a stated correction that is the photo's own, read in
  any of the 8 frames, counts as not stated, so the photo's own is kept
  and turned to the stated orientation. "The photo's own" is within 1e-5
  per element, each matrix scaled so its last element is 1: rounding to
  6 decimals moves an element by about 1e-6 at most, and corrections
  1e-5 apart move no corner by more than about 0.2 px at 6000 px.
  A copy of the settings read once and sent again after each change, or
  written with fewer decimals, used to store the correction in the old
  frame (15.5 px mean, 28.2 px max at 3200 x 4800).
- set_develop help: mentions the mode rule, and points agents to
  Copy / Paste or Sync for copying edits between photos (settings JSON
  from one photo carries that photo's correction).
- upright_transfer's module comment no longer says only the mode and
  sliders move between files, as Lightroom does.
- Docs: the settings JSON rules in xmp-interop.md and whats-new.md.
- Tests, failing on 725eca27: a local copy sent whole three times,
  re-rounded matrices (and corrections further away taken as given),
  a sweep of 24,576 sends over every file orientation, read orientation
  and later orientation, the same through MCP set_develop and through a
  lightcraft-cli run script.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:08:32 +02:00
Alex ChambersandClaude Opus 5.5 725eca2701 LR-META-XMP: whole-settings JSON turns the Upright correction; Paste after opening another library keeps each photo's own (#205)
Fourth review round on 6471d93b:

- develop.merge: a stated correction equal to the photo's current one
  counts as not stated, so settings sent back whole (develop.get /
  MCP get_develop, rotation changed) turn the correction as Rotate does.
  It stayed in the old frame, 15.5 px mean off at 3200 x 4800.
- The clipboard remembers the library it was copied in. After opening
  another library a photo with the copied photo's id is another file
  and keeps its own correction; it got the copied one.
- The Presets panel's preview and thumbnails use Session::preset_look,
  the helper preset.apply and both import paths use, so the preview is
  what Apply stores (it was up to 15.4 px off).
- "No crop" (HasCrop False, or the full frame unrotated) needs no frame:
  it applies on an unreadable or preview-only file without a warning
  and isn't listed again by Synchronize Folder. A held crop is news only
  while it differs from the photo's. The Crop* fields beside HasCrop
  False aren't reported as unimported.
- Synchronize Folder's scan and read learn the file's orientation
  through the session's file probe, inside import's memory budget, not
  by reading whole files on their own.
- Docs: guides and Constrain Crop move between files too; the Lightroom
  claim is attributed to community experts, with the conflicting Auto
  Sync reports and the stale same-mode correction noted.
- Tests: the regressions above (failing on 6471d93b), an MCP set_develop
  round trip, an older preset holding a correction, and same-file
  sweeps from copy to original and copy to copy (12,672 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:06 +02:00
Alex ChambersandClaude Opus 5.5 6471d93b10 LR-META-XMP: an Upright correction is the photo's own; sidecars are read in the file's current frame (#205)
Third review round. A saved Upright matrix (Lightroom's, or LightCraft's own) is the analysis of
one image, so it no longer travels to other files:

- Paste, Paste from Previous, Sync and Auto Sync move it only between a photo and its virtual
  copies, turned by the difference in their Rotate Left / Right. Between different files only the
  Upright mode and the Transform sliders move: the target keeps its own correction while its mode
  stays, else solves its own, as Lightroom does when it syncs the Upright mode. Main copied
  LightCraft's own correction verbatim to other files, one scene's correction applied to another.
- Presets keep the mode and sliders, never a correction (preset.create and preset.update strip it;
  an older preset's, on apply or on import, is ignored the same way).
- develop.merge (MCP set_develop, the CLI): a change of rotation alone turns the kept correction, as
  Rotate does, before the commit and Auto Sync's delta; a change of mode drops it; a correction
  stated in the JSON is taken as given.
- Nothing about the file's frame is recorded in the catalog any more (Photo::file_orientation is
  gone, so the catalog format is unchanged): Read Metadata from File and Synchronize Folder read
  the file's orientation from the file each time, so a file whose EXIF changed is read as it is now.
- A crop-only sidecar change that can't be placed is still listed by the scan, so Synchronize
  Folder reports why the crop was kept.

The new regressions fail on the previous commit and pass here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:12:14 +02:00
Matthew James SivakandClaude Opus 5.5 c678ab06d5 Help ▸ Debug ▸ Show Frame Rate: a frame-rate display for watching drags and zooms
A debugging toggle (`view.frameRate`, `{show?}`, a checked item in a new Help ▸ Debug
submenu; LIGHTCRAFT_FPS=1 starts with it on; never saved). In the window's top-right
corner it draws frames per second and the worst over the last second, each frame's UI
CPU time against its time spent waiting (vsync, or the GPU), the latest whole-frame
and zoom-window render times, the render queue, renders thrown away as superseded
(Renderer::discarded), the GPU adapter and its memory, and a graph of the last frames
with a tick where a render arrived. It keeps the window repainting while shown and
logs the same numbers once a second (`fps-hud:`), so scripted drags can be measured.

Perf::host_frame_ms (the frame's CPU time as eframe reports it) is added as #1009
adds it, identically.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:09:22 +02:00
radialmonster 71fb91daeb LR-IMP-FORMATS: RW2 format 8 decodes tiled images (DC-S1RM2 high-resolution mode, tag 0x0043 = 2) (#1276)
The high-resolution mode of the DC-S1RM2 (16296 x 10856) tiles the image in two
rows of four. Tag 0x0045 holds (x, y) u16 pairs per tile (y was always 0 in
single-row files, where the old 32-bit reading gave the same numbers), and tag
0x0043 is the number of tile rows. The decoder now takes each tile's position
from the lists, checks that the tiles lie inside the image, do not overlap and
cover it exactly, and that 0x0043 equals the number of distinct tile tops.
Tiles are decoded like strips. Other tag values are still refused.
2026-10-12 12:31:37 -04:00
Nathaniel Ledford fed72cd3c9 Merge branch 'settings-window-size' into settings-tab-control 2026-10-12 12:16:25 -04:00
Nathaniel LedfordandClaude Opus 5.5 99e58473b2 LR-PREF-GENERAL: the long-path test scrolls to the watched folder before reading it
The row is at the foot of Import. With more sections above it (another
pull request adds one) it is below the fold of the 640 pt page, and a row
that is not drawn has no text to read: the test failed for the page's
length, not for the path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 12:16:15 -04:00
Andrew FrahnandClaude Opus 5.5 6b7b686093 LR-EDIT-EFFECTS-DEHAZE: process V3 dehaze (patch dark channel, eased response, no black halos; #1237)
Dehaze estimated haze from a Gaussian of each pixel's darkest channel, so
ordinary tones read as haze (-0.5 EV on a haze-free textured ground at +50)
and shadows next to bright sky clipped to black from +10. Process V3 takes
the Gaussian of a patch minimum (0.5 % of the long edge, separable, O(1) per
pixel on the CPU; min_h/min_v on the GPU), eases the response in, floors
the transmission at 0.15 and never removes more veil than 90 % of the
pixel's darkest channel. V1 and V2 render as before.

Sources: He, Sun, Tang, "Single Image Haze Removal Using Dark Channel
Prior" (CVPR 2009); van Herk / Gil-Werman running min; own easing and veil
limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 02:17:19 +11:00
Alex ChambersandClaude Opus 5.5 1c37bda98f LR-META-XMP: Upright corrections move between photos in each photo's frame; Synchronize Folder reads sidecars in it (#205)
Second review round.

- Paste, Paste from Previous, Sync and Auto Sync copied a stored Upright correction as it was:
  a virtual copy turned right got the original's matrix (15.4 px mean, 23.2 px max at
  3200 x 4800), and so did a file with another EXIF orientation. They now re-express it from the
  source photo's frame into each target's through the stored image, as Lightroom keeps it
  (upright_frames). A patch that turns a photo without bringing a correction (a crop paste, a
  preset) turns the correction it keeps. Import records the file's orientation in the catalog
  (Photo::file_orientation, optional); photos imported earlier are taken as stored the way they
  are shown.
- Synchronize Folder read sidecars without the photo's frame: a sidecar whose only change was its
  time undid import's crop conversion and cleared the kept Upright, silently. Its scan and its read
  now use the recorded frame (else the file's), and its report carries the sidecars' warnings.
  Without any frame an existing photo keeps its crop, and the report says so.
- The Lightroom catalog import's XMP column dropped its diagnostics (Guided guides vanished); they
  now go into the per-image report, once alongside the develop settings' ones.
- The release note says what isn't covered yet.

The new regressions fail on the previous commit and pass here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:09:50 +02:00
Nathaniel LedfordandClaude Opus 5.5 f9e2916ec7 LR-PREF-GENERAL: the Settings tabs look like tabs, and what is chosen or has the focus shows
One widget drew the tab bar and every row of choices: on General the
languages read as a second tab bar. What was chosen differed from what was
not by its fill alone, 1.35 to 1.56:1 in the four themes, and a tab or choice
reached with Tab showed nothing (the frame with and without the focus was
the same, pixel for pixel).

- widgets::tab_button: a tab is its name on the window, a size up from the
  headings inside a page, the open one in the text colour over a 2 pt line
  of that colour; no border, no fill. Each line of tabs stands on a 1 pt
  line the width of the window, so the open tab's line never hangs over the
  names below when the tabs take two lines (a narrow window, or Russian at
  full width).
- widgets::choice_button, used by settings::choices: the chosen option keeps
  its fill and is outlined in the label colour (5.5:1 or more against its
  fill, a button and the window in every theme), and is a radio button to a
  screen reader. The Export dialog's own rows of choices are as they were.
- Both, and text_button wherever it is used, show a ring while they have the
  keyboard focus: 2 pt in the text colour with a 1 pt line of the surface's
  colour inside it, within their rect. A double line, where a chosen option
  has a single thin one, so the two are told apart by shape. Tab, the arrow
  keys, Space and Return already worked.

Widget ids are as they were (button:settingsTab-{id}, button:{id}-{index}).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:01:05 -04:00
Nathaniel LedfordandClaude Opus 5.5 35cdbb1ca8 LR-PREF-GENERAL: the Settings window is one size on every tab, and nothing in it runs off the side
General was 970 pt wide and the other tabs about 600, because a row of
controls never wrapped and the widest row set the window's width; the
window was also as tall as each tab's content, so it moved up and down
between tabs and the tabs moved under the pointer. In a 900 pt wide app
window the row labels and the Close button of General were off screen at
the left, and a long library path made the window wider than the screen.

- settings::window draws the tab bar, which stays put, above the page of the
  open tab, which scrolls: 560 pt wide and up to 640 pt tall whatever the
  page holds, less when the app window is smaller. Each page keeps a scroll
  position of its own, and one that goes on below shows its scroll handle
  without the pointer on it. It is still drawn in the scroll area every
  dialog has, which has nothing to scroll; in a window with under 120 pt for
  the page (far below the desktop minimum) that area scrolls tabs and page
  together, as before.
- A row's controls wrap in the room beside the label, further lines starting
  under the first control, and a label longer than its column wraps in it
  (Forget unchanged photos pushed its choices 8 pt out of line).
- Paths are one line, shortened from the front after either kind of
  separator, with all of the path in a tooltip only when it was cut: Library
  > Location, the folder Auto Import watches (it wrapped over several lines),
  the smart previews folder (it lost its end, and had two tooltips) and a
  monitor profile's file.

row(), choices() and TABS keep their signatures, the match on the tab is
where it was and the shared dialog code changes by one call, so the open
pull requests that add tabs and rows still merge. Below 640 pt of app
window a row holding a 240 pt field can still be wider than the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:57:21 -04:00
Alex ChambersandClaude Opus 5.5 7bb11f4604 LR-META-XMP: keep saved Upright in the photo's frame, put sidecar crops there too (#205)
Review fixes for the saved Upright import.

- The saved matrix is placed when the sidecar is merged, in the frame the file's orientation and
  then the photo's own Rotate Left / Right give (Orientation::then). Read Metadata after a
  rotation no longer keeps it in the old frame (up to 21 px off on DSC_1457). Rotate Left / Right
  turns a stored Upright correction with the photo (upright::turned), LightCraft's own as well.
- Sidecar crops go through the Lightroom catalog import's conversion (catalog_crop, moved to
  crs::crop_in_frame unchanged) whenever the file's orientation is known. A portrait raw's crop
  used to cut the wrong side (DSC_1486 rendered 2548 x 4812 for Lightroom's 3200 x 3808; now
  0.12 px mean from Lightroom's export after the NEF frame inset), and straightened crops from a
  real catalog's settings came out 8459 x 3827 for Lightroom's 7725 x 5150. A straightened crop
  on top of a saved Upright isn't measured: the mode is solved again, with the warning.
- Lightroom catalogs don't read the saved Upright until a real catalog verifies its encoding and
  frame: the mode is solved again and the import reports it.
- Guided Upright guides are reported whether or not automatic matrices are present.

The new regressions fail on the previous commit and pass here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:12:15 +02:00
Matthew James SivakandClaude Opus 5.5 86f4bde867 LR-VIEW-CLIPPING: Alt-drag previews keep the histogram on profiled displays; NR previews (#937)
Folds the parts of #995 that #999 lacked into #999:

- engine/media.rs: with a display profile, a diagnostic view keeps the
  photo's histogram (taken before the overlay is drawn) and stays out of the
  view cache; the clipping previews test sRGB values and are converted for
  the display afterwards. Before, holding Alt on a P3 display swapped the
  histogram for the clipping view's (mean luma 0.59 -> 0.999).
- Noise Reduction Luminance / Detail / Contrast show greyscale while
  Alt-dragged (Overlay::for_control).
- Tests: diagnostic_views_keep_the_photos_histogram (P3 display; fails
  without the media.rs change), an end-to-end headless drag that presses
  and releases Alt mid-drag and checks the loupe pixels
  (tests_alt_preview.rs), and every Alt-mapped id is a real control.
- Docs: NR row, What's New and ui-parity note; parity summary refreshed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:53:24 +02:00
Matthew James Sivak 77cfb94c2f Merge remote-tracking branch 'origin/main' into combine-alt-previews 2026-10-12 13:46:59 +02:00
Matt Markham 2cb203fab0 Contributors: add mdlmarkham (sync GitHub name) 2026-10-12 07:36:28 -04:00
Alex ChambersandClaude Opus 5.5 aaf3094c8d LR-META-XMP: keep Lightroom's saved Upright geometry from XMP and catalogs (#205)
Lightroom saves one Upright matrix per mode (crs:UprightTransform_0 to _5). LightCraft read
only the mode and solved the photo again, so on public references its framing was 23 to 78 px
away from Lightroom's export on average. The selected mode's matrix is now kept where the
photo's frame is known (photo import, Read Metadata from File, Lightroom catalog import) and
turned from Lightroom's stored-image frame into LightCraft's oriented, centred one.

The convention was measured on Lightroom Classic 12 exports of public NEFs (one shown in
portrait, one cropped with Constrain Crop): a forward homography on column vectors, normalised
to the stored image (y down, before the EXIF orientation), applied before the crop, with the
fill scale in it. LightCraft's render mapping with the kept matrix matches those exports to
0.11 to 0.54 px on average and under 1 px at worst; end-to-end renders match to 0.08 to 0.50 px.

PerspectiveUpright 2, 3 and 4 now read as Full, Level and Vertical (they were read as Level,
Vertical and Full), and a stated mode no longer keeps a transform solved for another one.
Guided, Upright versions other than 151388160 and unusable matrices are solved as before, with
the import warning. Upright Off no longer warns, and Lightroom's Upright bookkeeping fields are
no longer reported as unimported adjustments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 12:59:42 +02:00
Alex ChambersandClaude Opus 5.5 3e176be41a LR-META-EXIF-FULL: review fixes for HEIF metadata (#693)
- Remove location: export checked only the photo's own EXIF, so an
  original HEIC whose GPS was in its thumbnail's EXIF or a second EXIF
  item was copied out with it. lightcraft_meta::embeds_location reads
  every HEIF EXIF and XMP item, and one it can't read counts as a
  location; other formats are checked as before. The refusal offers DNG
  only for raws (DNG export of a JPEG or HEIC fails).
- iloc is walked once for all candidate items instead of once per
  candidate (a hostile 48 MB iloc with 64 items took over a second).
- EXIF tied to an image the primary is made from (dimg: iden, iovl,
  tmap, grid inputs, followed with a seen-set) is the photo's; a pitm
  naming no item counts as no primary.
- Full-box versions are read from the box's own payload.
- meta README lists HEIF in embedded and ISO/IEC 14496-12 and 23008-12
  in its sources; target-app-parity no longer cites HEIC (#693) as a
  file-exchange discount reason.

Tests, failing before: GPS in a thumbnail's EXIF, a second EXIF item or
an XMP item, and the unsure cases; a work bound counting iloc entries
walked; derived primaries and a missing pitm item; a HEIC original
refused under remove location; DNG offered only for raws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:47:59 +02:00
Atmik Shetty 56e65bf49f KEYC-PANELS: F8 toggles the right panel, Shift+Tab hides and restores all panels
View > Right Panel (view.rightPanel, F8) hides the right panel and shows the one that was
there again (Edit when nothing was remembered; Crop, Remove, Masking and Red Eye end when
hidden and come back as Edit). View > Hide All Panels (view.allPanels, Shift+Tab) hides the
left sidebar, the right panel, the Presets column and the filmstrip; the next press brings
back exactly what was shown, so a panel that was already hidden stays hidden. Showing any
panel by hand while they are hidden makes the snapshot stale: the next Shift+Tab hides again
instead of resurrecting old state. Both commands take an optional show / hide parameter and
are checked in the View menu while they apply. The top bar and tool strip stay.

Shift+Tab is also egui's "focus previous widget", set when the pass begins and without
consuming the key; with nothing focused it focused the loupe. The shortcut consumes the key
and cancels the traversal. In a focused text field neither key is a shortcut, and Shift+Tab
keeps moving focus as before. On a Mac keyboard F8 is Play/Pause unless fn is held or
"Use F1, F2, ... as standard function keys" is on.

Docs: KEYC-PANELS is partial (Tab, T, F5 and solo remain; F6 / F7 are in PR #1078), KEYC-HELP
is done (F1), KEYC-RATING's missing list drops the already implemented [ ] keys, two View
menu rows are added, gap 28 shrinks, and the parity summary is refreshed with
cargo xtask parity --write (it was already stale on main).

The native menu leaves Shift+Tab to egui (no accelerator, so the macOS menu shows no key hint) and
the native_menu accelerator test lists it with the other keys egui handles.
2026-10-12 13:37:28 +05:30
Alex ChambersandClaude Opus 5.5 4e8515aaab LR-META-EXIF-FULL: read HEIC, HEIF and AVIF EXIF and XMP from the container (#693)
Imported HEIC photos had no camera, capture time or All Metadata rows:
lightcraft_meta::embedded had no HEIF branch, and only the decoder read the
EXIF, while decoding pixels. crates/meta/src/heif.rs reads the primary
image's Exif item and XMP (mime) item from the ISO BMFF item structure
(meta: pitm, iinf, iref cdsc, iloc versions 0 to 2 with construction
methods 0 and 1 and idat) and honours the Exif item's TIFF-header offset.
No picture is decoded, so it works without the heif feature, and AVIF goes
through the same code. Offsets and lengths are checked; boxes, candidates
and item sizes are bounded. The CR3 box walker is reused.

Tests: the issue sweep's synthetic-metadata.heic case, EXIF offsets,
extents and construction methods, primary-image association, malformed
bounds, and a truncation, byte-damage and property sweep (meta); HEIC
import to the catalog and All Metadata, and AVIF export metadata reading
back (engine).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:52:35 +02:00
Claude 17d56cb76a LR-EDIT-OPTICS-PROFILE: script to measure Sony distortion against camera JPEGs
Renders each ARW with the embedded correction on and off and compares it with the camera's
own JPEG: residual offsets by radius after a similarity fit, checkerboard line straightness,
and the ratio of the camera's correction to LightCraft's. Checked on synthetic boards with
known radial distortion (corrected: 0.04 px median, ratio 1.000; 80 % corrected: ratio 1.26,
expected about 1.25-1.28). Needs numpy, scipy and opencv-python-headless.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
2026-10-12 05:40:01 +00:00
Nathaniel LedfordandClaude Opus 5.5 d6792ffddf Synchronize Folder: work on the folder's photos meanwhile no longer restarts the scan
Found using the app on a real library: a folder of 8,700 files on a network share takes about a
minute to scan, the dialog says to go on working, and any rating, label or keyword given to one
of the folder's photos in that minute made the finished scan count as stale. Synchronize then
refused it and scanned again, every time.

A scan is current while the folder holds the same photos (virtual copies aside), each the same
file in the same place. What the person does to those photos does not change which files are
new, missing or moved. It matters only for a photo whose sidecar the scan found news in:
reading that would go over the work, so there nothing may have changed, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 21:13:29 -04:00
Andrew FrahnandClaude Opus 5.5 29a2445a40 LRC-LIB-CATALOG-IMPORT: reimport keeps an earlier static album static and counts albums by their kind
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:39:34 +11:00
Chad ElliottandClaude Opus 5.5 f6b41efb16 perf(ui-egui): #1057 sidebar caches survive develop edits
Library-wide caches that don't read develop state (folder/keyword trees,
keyword chips/ticks/names, counts, person names, By Date, filter-bar values,
suggestions, smart-album problems, the Missing count, face hints) key on the
library revision; people, album counts and the rule dialog's count key on
the view revision. The justified grid layout adds the catalog revision to
its key since crops and rotations change cell shapes without changing the
list. The filmstrip shares the visible list instead of copying it per frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:16:00 -05:00
Chad ElliottandClaude Opus 5.5 dfd6cbeea6 perf(engine): #1057 slider ticks don't redo the grid query
Session::visible() and source_total() key on a structured ViewKey compared
field by field (no per-frame Debug format of the filter) and on the view
revision, so a develop edit re-runs the query only when the filter, sort or
smart-album source reads develop state. At 100k photos a drag tick drops
from 68 ms (capture-date sort) / 27 ms (file name) to under 0.01 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:16:00 -05:00
Chad ElliottandClaude Opus 5.5 f80c3778bd perf(catalog): #1057 count develop-only ops in a separate library revision
Catalog::library_revision is bumped by every op except develop-only ones
(SetDevelop, SetHistory, PushHistory, SetVersions and batches of them);
revision keeps counting every op. Filter/Sort/RuleSet::reads_develop and
Catalog::{filter,album,smart_albums}_read(s)_develop say whether a view can
change with a develop edit (through Album rules, loops ended by a seen-set),
and Catalog::view_revision gives the key such a view caches on. Rule fields
are classified by an allow-list, so a new field counts as reading develop
state until it is classified on purpose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:16:00 -05:00
Matt MarkhamandGLM 5.3 Flash 05c9affa2d LRC-LIB-CATALOG-IMPORT: the ARW6 plan keeps the probe's scattered strip reads
The equivalence of `header_plan` / `preview_plan` broke for the corpus's
ARW6 pair: a Compression-32766 strip was holed behind its first 64 KiB
prefix, but the header probe validates more there - `parse_tile_header`
walks the 13 stream header words of every tile, `stream_halves` reads
each stream's index table, and `first_half_shift` decodes the residual
stream's first half, which sits behind the earlier streams' data. The
probe read zeros (and 0xFF) there and failed with "ARW6 first-half row
count outside 2..=7" where the whole file decodes (first corpus case:
arw6-sony-ilce7rm6-apsc-land-hq.arw; the corpus is git-ignored, so CI
never saw it).

`tiff_plan` now asks `arw6_keep` (crates/raw/src/sparse.rs) for those
scattered ranges when `image()` reports Compression 32766 for one strip:
the tile table, then per tile, in staged rounds so each ask sits in one
`Need`, the picture/group tables, the stream header words (positions
from the new `arw6::stream_header_positions`, mirroring the group-table
walk), then with the real `parse_tile_header` run, the 13 index tables
and finally each residual first half (its length A is its index's first
entry). A layout that cannot be walked - a corrupt ARW6 - reads whole:
`arw6::decode` stops at the same validation of loaded bytes, so the
probe is still identical. `parse_tile_table`/`parse_tile_header`/`u16_be`
/`u24_be` are now `pub(crate)` for the walker, and `Want::pending_plan`
(renamed from `into_plan`) takes `&mut self` so the walk can keep asking
after the IFD walk answered.

The full-size JPEG these files carry as a `JPEGInterchangeFormat` image
(compression 6/7, where 0x0201/0x0202 are the interchange tag and its
length and `image()` therefore declines) reached no hole at all and made
the plan read it whole: together with the strip work, the header plan
now reads 26-28% -> ARW corpus average 1.7% (the two ARW6 files read
2 / 1%); nothing reads the interchange image in header mode, and under
`preview_plan` `embedded_preview` wants it, so the hole is header-mode
only (the corpus test's 0 / 0xFF fills guard both).

Tests: without the corpus, sparse.rs's synthetic single- and two-tile
ARW6 files (residual stream past the prefix, Qis::REAL) assert the plan
reads past the 512 KiB prefix and probes as the whole file does, for
both fills; with it, `sparse_corpus` passes for both real files, both
fills, both plans, and the ARW6 decode-sum corpus tests are unchanged.
Gates: fmt, clippy -D warnings, heif, parity (+ refreshed summary),
layers, assets and wasm all pass; `cargo test --workspace` has no
failure that clean main doesn't show on this Windows machine (engine
sidecar's 20 fs-fault and ui-egui's denoise-model tests fail there
identically with the fix stashed).

Co-Authored-By: GLM 5.3 Flash <[email protected]>
2026-10-11 18:13:35 -04:00
Chad ElliottandClaude Opus 5.5 3d241724c4 docs: #1050 gap 9 shrinks: tests in CI on the three desktop platforms
ci.yml exists, but main does not pass it yet. The #989 fixes and the
Windows ones (#1052) are still outstanding, so the entry stays open:
6-12 h -> 2-4 h. Headline numbers are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:56:57 -05:00
Chad ElliottandClaude Opus 5.5 1da831e4d0 ci: #1050 run the tests on Linux, macOS and Windows
On every pull request and push to main, on standard GitHub-hosted runners:
Linux runs `cargo xtask ci`. macOS and Windows run clippy and
`cargo test --workspace --no-fail-fast`, the steps whose result depends on
the platform.

All three then run the GPU test targets on lavapipe, Metal and WARP. That
step fails if the tests still skip for lack of an adapter.

The dependency cache is saved from main only. The workflow uses only the
actions and pins already in release.yml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:56:57 -05:00
Chad ElliottandClaude Opus 5.5 99b89cd986 perf(gpu): #1145 keep mask alphas in GpuStages
The GPU stage cache mirrors the CPU's: per-mask alpha buffers keyed by
masks::alpha_key, copied into the combined alpha buffer on a hit (no
readback for CPU-evaluated shapes). GpuStages::bytes counts them and the
chromaticity buffer it left out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:12 -05:00
Chad ElliottandClaude Opus 5.5 2d85c4a39d perf(pipeline): #1145 keep mask alphas in the stage cache
Each visible mask's alpha is kept with the stage cache entry and reused
while nothing it reads changes: its components, Refine Edges, Invert and
Amount, the frame and output size, plus the image (lin_key) and the
exposure for the masks that read them. An Exposure drag over gradient and
brush masks, or a drag of a mask's own sliders, evaluates no mask.

StageCache::bytes now counts the alphas and the blurred chromaticity
plane, which it left out. The serial w x h loops of mask evaluation run
row-parallel with unchanged arithmetic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:12 -05:00
Chad ElliottandClaude Opus 5.5 d499b4e4d9 perf(engine): #1145 render_bench scenario for drags over masks
Three masks (radial, brush, and a linear gradient with Refine Edges or a
Subject mask): a global Exposure drag and a mask's own Exposure drag, at
loupe and draft size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:12 -05:00
Chad ElliottandClaude Opus 5.5 a06277b7d3 test(gpu): #1050 opt-in software adapter for the equivalence tests
`Gpu::new` refuses software adapters (llvmpipe / lavapipe, WARP): the CPU
pipeline is faster. CI runners have nothing else, so the CPU <-> GPU
equivalence tests have always skipped there.

`LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` admits them. The app never sets it and
the default is unchanged. On lavapipe all 134 compared renders stay within
max 1 LSB, mean <= 0.0003 LSB, the same as Metal on an M4 Pro.

The two big-source window tests used a 24 MP (288 MB) source. That is over
lavapipe's 128 MiB storage binding (the WebGPU default), so geometry ran on
the CPU and nothing was uploaded: one test failed and the other passed
without checking anything. They now use 3200 x 3200 (117 MiB): above
RETAIN_SOURCE_BYTES, so the source is still shared, and uploaded on every
adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:51:46 -05:00
Matt MarkhamandGLM 5.3 Flash 0d320e61d6 Batch 31: parity summary refreshed (cargo xtask parity --write)
Co-Authored-By: GLM 5.3 Flash <[email protected]>
2026-10-11 17:47:21 -04:00
Nathaniel LedfordandClaude Opus 5.5 0c7f5bf398 Synchronize Folder: a scan started before the first frame is not let go of
Letting go of a scan when another library is opened ran the first time a
library was shown too, at launch. A Synchronize Folder dialog opened before
that frame (a script, a test) was closed and its scan dropped. It now runs
only when a library replaces the one shown before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:17:13 -04:00
Matt MarkhamandGLM 5.3 Flash 39ff9f9bd5 ci: clippy on stable 1.96 and the Windows-only pre-existing failures
Three boolean expressions clippy now wants simplified (pipeline's
normalize_wb, engine's learn_as_shot_wb, per nonminimal_bool), a
descriptor_copy only its #[cfg(unix)] test calls (dead on Windows), and
desktop_entries_give_their_program is unix-gated (Path::is_absolute
decides by the platform, and desktop entries run there). None change
behaviour; all four fail cargo xtask ci on this toolchain from main.

Co-Authored-By: GLM 5.3 Flash <[email protected]>
2026-10-11 17:15:48 -04:00
lloydandClaude Opus 5.5 3ce8056dab MENU-HELP-REPORTBUG: Help ▸ Report a Bug… (#1015)
A new Help item next to Send Feedback… (which stays a plain new issue). The
dialog takes what happened, hides the home folder and photo file names by
default, can add the two previous runs' logs, and previews the issue. Create
Report writes lightcraft-report-<time>.txt next to the logs (what happened,
the System Info rows and the logs, each up to its last 4 MB), reveals it, and
opens a new GitHub issue pre-filled with the same and the end of this run's
log; the link is kept under 8,000 characters. Nothing is sent by the app.
Without a log (web, --memory) the issue holds the system info only.

The System Info rows move to `bug_report::system_rows`, shared by both dialogs.
`app.reportBug {create: true}` does it without the dialog (agents, tests).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:09:37 +02:00
Nathaniel LedfordandClaude Opus 5.5 738a49aa47 LRC-LIB-LIBFOLDERS: Synchronize Folder's scan can continue in the background
The scan already ran on a worker thread, but its dialog was modal and closing
it stopped the scan, so a large folder on a network share held the app for
minutes. While the folder is scanned the dialog's button is now Continue in
Background: the dialog closes and the scan goes on under its activity row.

When it is done nothing pops up. A notice in the activity stack says how many
changes were found (or that the folder isn't there); a click opens the dialog
with that result and what was ticked, without scanning again, and ✕ lets go of
it. A scan that found nothing to do only says so in a toast. Cancel and the
row's ✕ stop the scan as before.

One folder at a time: Synchronize Folder on another folder is refused while a
background scan runs or its notice waits, so minutes of scanning are not
thrown away unasked. Another library opened lets go of the scan. A result
looked at later can be stale: Synchronize already refuses a scan whose
folder's photos changed meanwhile and scans again.

The dialog's one button reads Continue in Background and then Synchronize, so
Synchronize is unavailable for 0.6 s after the scan ends: a click meant for
the first can't start the second.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:46:10 -04:00
Jason Baldwin 6467c96e16 LR-IMP-AUTO: Auto Import can include subfolders of the watched folder (#1109)
Settings > Import > Auto Import gains Include subfolders (off by default:
the watched folder's top level only, as before). On, the scan also lists
its subfolders' photos: hidden entries, links to folders and the library's
own folder (where copy-mode arrivals go) are skipped; at most 16 levels and
200,000 entries, with a warning when a bound is hit. An unreadable
subfolder or a non-Unicode name is skipped with a warning.
library.autoImport takes subfolders; library.autoImportScan reports it and
listingWarnings.
2026-10-11 22:11:05 +02:00
s17sh70x2jfl031ag 465601b76c HEIF: move the heic-rs pin from 0.1.1 to 0.1.4
heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing:
four in release builds (a uuid box too small for its extended type, a
block starting right of the picture, a conformance window larger than the
picture, and a reference row below the picture) and two with overflow
checks on (a PCM bit depth above the sample bit depth, a slice QP out of
range). The API, MSRV and output are unchanged.

Mirrors the same change in PhotoCraft's photocraft-heif.
2026-10-11 12:46:22 -07:00
Andrew FrahnandClaude Opus 5.5 7361cb222c LR-IMP-FORMATS: MRW gets the file-local look fitted to its preview
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:15:53 +11:00
Andrew FrahnandClaude Opus 5.5 6f92e24440 LR-EXP-RENDERDNG: Render to DNG runs on a worker in the desktop app
The engine half is split into `render_dng::prepare` (session: params, per-photo
render setup), `RenderDngJob::run` (no session: render, encode, write next to
the original) and `render_dng::finish` (session: import, stack on the original,
select). `photo.renderToDng` still runs all three in a row (CLI, MCP, web,
scripts). The desktop app intercepts the command when it has a thread-safe
writer and runs `run` on a worker thread, like background exports: the
request answers at once with {background, total}, the `renderDng` activity row
shows progress and its ✕ cancels between photos, and each frame polls for the
result, then adds the DNGs and shows a toast. A panic in the worker is caught
and reported. Toast and labels translated in all nine catalogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:38:09 +11:00
Andrew FrahnandClaude Opus 5.5 f239818d28 LR-EXP-RENDERDNG: Photo > Render to DNG adds rendered DNGs stacked with their originals
New command `photo.renderToDng` (Photo menu): each selected photo (at most
500) is rendered with its edits as a linear 16-bit float DNG
`<name>-Rendered.dng` next to its original (never overwriting; a demo photo
needs `dir`), added to the library stacked on top of the original, and the new
photos are selected. Colour space proPhoto by default (rec2020, adobeRgb,
displayP3, srgb). A batch holds a cancellable `renderDng` activity row with
progress; cancelling stops between photos and keeps the copies made so far;
a failing photo in a batch is listed and the rest carry on.

Stacked on #1047.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:03:29 +11:00
Andrew FrahnandClaude Opus 5.5 d6c29aaf25 LR-IMP-FORMATS: decode Minolta MRW (packed 12-bit and 16-bit words, RGGB / GBRG)
The \0MRM block container: PRD sensor size, storage and Bayer cell; WBG
as-shot gains in cell order; TTW TIFF for Exif and orientation. Black 0,
white from the saturation plateau. 4 CC0 samples pinned in RAW_SAMPLES;
DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified. Also fixes
clippy::chunks_exact_to_as_chunks in pipeline/spots.rs.

Sources: public prose description of the MRW layout (Dalibor Jelinek's
MRW notes) and own black-box analysis of the CC0 samples.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:32:29 +10:00
FredApps 44d5cf7590 #798: reconcile page-backed catalog import with upstream
Preserve page-backed large-catalog import while integrating upstream catalog and documentation changes. Adapt only the sparse test fixture to the shared upstream fixture helper.

Use the equivalent complete RGB-triple iterator required by Rust 1.99 Clippy; the same lint failure was reproduced on the exact upstream baseline.

Validation: 47 catalog tests, nine spot tests, exact sparse regression, all eight CI gates, and an original synthetic headless import/selection check passed.

Generated with Codex.
2026-10-11 17:52:32 +02:00
Andrew FrahnandClaude Opus 5.5 fc889bead4 LRC-LIB-CATALOG-IMPORT: Lightroom import maps smart collections to smart albums and folder stacks to stacks
Smart collections whose rules all have an equivalent (rating, pick, label,
edited, ISO/focal/aperture, text rules, nested groups) become smart albums;
others stay static with the unmappable rule named in the report. Folder
stacks keep Lightroom's order and collapsed state; reimport skips photos
already stacked. Report gains smartAlbums, smartAsStatic and stacks.
Also satisfies clippy 1.99's chunks_exact_to_as_chunks in spots.rs.

Part of #197.

Sources: own design, from the Lua rule literal structure in public prose
write-ups of the catalog format and synthetic test catalogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 02:48:45 +11:00
Fabio LussiandClaude Opus 5.5 5d846aa65d Choose the thumbnail cache folder per library, like smart previews
Settings > Performance > Thumbnail cache gets Choose Folder... / Use Library
Folder next to the size limit, backed by the new library.thumbnailCacheLocation
command. The folder is saved in prefs.json, must be absolute, and is checked
before anything moves; cached thumbnails are moved, left or deleted on a
change. A chosen folder missing when the library opens is reported and never
recreated (thumbnails stay in memory), and the disk cache no longer creates
missing parent folders.

Also switches one chunks_exact in spots.rs to as_chunks, which clippy 1.99
flags on main.

Fixes #903

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:25:56 +02:00
Fabio LussiandClaude Opus 5.5 9ac38222e1 Alt/Option-drag previews for clipping and sharpening
Holding Alt (Option on macOS) while dragging an Edit slider shows
Lightroom's temporary previews: highlight clipping for Exposure,
Highlights and Whites, shadow clipping for Shadows and Blacks, greyscale
for Sharpening Amount, the detail band for Radius / Detail and the
sharpening mask for Masking. They are render overlays (pipeline
visualize), drawn the same way after the CPU and GPU renderers; the
sharpening views read each renderer's own detail planes. Settings and
normal output are unchanged.

Fixes #937

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:25:47 +02:00
FredApps b273f8e608 #798: read large Lightroom catalogs by page
Generated with Codex.
2026-10-11 00:53:36 +02:00
185 changed files with 14689 additions and 851 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: Bug report
description: Report incorrect behavior, a crash, or a RAW decoding problem.
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Search [existing issues](https://github.com/storytold/lightcraft/issues?q=is%3Aissue) first. If the same problem is reported, add reproduction details there.
Issues are public. Remove secrets, personal paths, and private photo metadata from logs, screenshots, and samples.
- type: input
id: version
attributes:
label: LightCraft version
description: Copy the version from the About dialog, or give the commit hash for a source build.
validations:
required: true
- type: input
id: environment
attributes:
label: Operating system and hardware
description: Include the OS version and CPU architecture. For the web app, also include the browser and version.
validations:
required: true
- type: textarea
id: behavior
attributes:
label: Actual and expected behavior
description: Describe what happened and what you expected. Include the exact error message if there is one.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Give the shortest sequence that shows the problem. Say whether it happens every time.
placeholder: |
1. Open or import ...
2. Select ...
3. ...
validations:
required: true
- type: input
id: camera
attributes:
label: Camera, lens, and file format (if relevant)
description: For RAW problems, include the camera model, RAW mode or compression, and whether the camera JPEG looks correct.
- type: textarea
id: evidence
attributes:
label: Screenshots or a small sample (optional)
description: Share only material you can make public. Prefer a generated or CC0 sample; remove private metadata. Do not attach Adobe assets or profiles.
- type: textarea
id: logs
attributes:
label: Relevant logs (optional)
description: Paste only the relevant, redacted excerpt. Do not include credentials or private file paths.
render: text
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
@@ -0,0 +1,33 @@
name: Feature request
description: Describe a missing photo workflow or an improvement to an existing one.
title: "[Feature]: "
body:
- type: markdown
attributes:
value: |
Search [existing issues](https://github.com/storytold/lightcraft/issues?q=is%3Aissue) first. Use a reaction to support an existing request, or add details about your workflow there.
Issues are public; keep private photo metadata, file paths, and credentials out of examples.
- type: textarea
id: problem
attributes:
label: What workflow is missing or difficult?
description: Describe the task you are trying to complete and who would benefit.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Desired behavior
description: Explain what LightCraft should do, with a concrete example if possible.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives or workarounds (optional)
description: Describe what you use today and why it does not meet your needs.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Include relevant OS, camera, or file-format details. Share only material you can make public; do not attach Adobe assets or profiles.
+50
View File
@@ -0,0 +1,50 @@
name: Performance problem
description: Report slow imports, rendering, navigation, or a large-library slowdown.
title: "[Performance]: "
body:
- type: markdown
attributes:
value: |
Search [existing issues](https://github.com/storytold/lightcraft/issues?q=is%3Aissue) first; add measurements to an existing report when possible.
Issues are public. Redact secrets, personal paths, and private photo metadata before sharing evidence.
- type: input
id: version
attributes:
label: LightCraft version
description: Copy the version from the About dialog, or give the commit hash for a source build.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Operating system and hardware
description: Include OS version, CPU, RAM, GPU or driver, and storage type (internal disk, USB, or network). For the web app, include the browser and version.
validations:
required: true
- type: textarea
id: workload
attributes:
label: Library and workload
description: Include the number of photos, file types or camera models, image dimensions, active edits, and the operation that is slow. Estimates are fine.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Include whether this is the first run or a repeat with cached previews, and whether it happens on a small library too.
validations:
required: true
- type: textarea
id: timings
attributes:
label: Measured and expected performance
description: Give elapsed time or frame rate if possible, what you expected, and whether the UI freezes or can cancel the operation. Approximate measurements are fine.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs (optional)
description: Paste a small, redacted excerpt. Remove credentials and private file paths.
render: text
+69
View File
@@ -0,0 +1,69 @@
<!--
Title: start with the checklist id or the issue, as the commits do:
`LR-IMP-FORMATS: folder import finds Sony SR2 and SRF files`, or `Issue #123: …` when there is no checklist row.
One change per pull request. Unrelated fixes and clean-ups (clippy, formatting, a second feature) go in their own:
small ones land fast, bundles get sent back to be split. The rules are in AGENTS.md.
Delete the sections and lines that don't apply, and these comments. A translation or a one-line fix needs only the
first three sections.
-->
Fixes #
<!-- `Part of #123` when this does only part of the issue. Built on another open pull request? Say so here
(`Stacked on #123`) and name the commits that are this one's own. -->
## Problem
<!-- What is wrong or missing, as a person using the app meets it. For a bug: the cause. -->
## Change
<!-- What you changed and where. -->
**Not in this PR:** <!-- what you left out on purpose, and known limits -->
## How it was checked
- [ ] `cargo xtask ci` passes on this branch, rebased on current `main`
- [ ] A test that fails without this change (for a crash fix: a regression test that panicked before it)
- [ ] User-visible change: ran the app and looked at the result
<!-- Numbers, when it changes rendering, decoding or speed: before and after, on more than one photo or camera
(ΔE against the reference, `cargo xtask bench`, `/usr/bin/time`, memory). Name the files or corpus entries used. -->
## Screenshots
<!-- For a visible change: before and after, labelled. Never a screenshot of an Adobe app. -->
## Merging
<!-- Pull requests land in batches, merged together. This is what the person merging can't see in the diff. -->
- **Overlap:** <!-- open pull requests that fix the same issue or change the same functions, by number, and how
this one differs. Search the open pull requests for the issue number and checklist id first. "none found" is fine. -->
- **Existing edits and libraries:** <!-- does any photo already edited render differently, or any saved setting,
preset, sort order or default change? Then it goes behind a new process version (docs/process-versions.md) or is a
setting that is off by default, with a line in docs/whats-new.md. "unchanged" is fine. -->
- **Shared numbers taken:** <!-- catalog format `VERSION` (crates/catalog/src/journal.rs), `RENDER_CACHE_VERSION`,
`LOOK_VERSION`, process version: the number this takes. If another pull request lands with it first, take one
past main's when you rebase. -->
- **Where it came from:** <!-- for a decoder, camera or lens data, or fitted constants: the public spec, your own
measurements, or observed output, and the files measured. Never GPL / LGPL code (dcraw, LibRaw, rawspeed, darktable,
RawTherapee, lensfun …), text derived from it, or Adobe profiles. -->
- **New dependencies, `unsafe`, build scripts, network access, spawned processes, deleted files, CI or `xtask` gate
changes:** <!-- each with its reason, or "none". Dependencies are pure Rust and use `workspace = true`; actions are
pinned by SHA; downloads are checked against a sha256; nothing leaves the machine unless the person asked. -->
## Checklist
- [ ] No new panic path in non-test code: no `unwrap` / `expect` / `panic!`, no indexing or unchecked arithmetic on a
number from a file, a person or an agent (`get`, `checked_mul`, NaN and char boundaries handled)
- [ ] New text on screen goes through `tr()` and has its key in `crates/ui-egui/locales/*.json`; a new shortcut is
free in `docs/library-shortcuts.md`
- [ ] The feature's rows in `docs/parity-checklist.md` and its entry in `docs/gaps.md` are updated, honestly (🟡 when
it is partial), in the same commit
- [ ] New code has a caller: no command, module or setting that nothing reaches yet
- [ ] Every new image, icon or other asset has its entry in `assets/ATTRIBUTION.md`; no font or media files added
- [ ] Ready to merge (otherwise open it as a draft)
+140
View File
@@ -0,0 +1,140 @@
# Tests on the three desktop platforms, on every pull request and every push to main (gap 9).
#
# - Linux runs `cargo xtask ci`, the same gate contributors run before a commit (fmt, clippy,
# heif, tests, parity, layers, assets, wasm), so whatever is added to it is checked here too.
# - macOS and Windows run what differs per platform: clippy and the workspace tests (code behind
# `cfg(target_os)` only compiles there; issue #989). fmt, parity, layers, assets and wasm give
# the same answer everywhere, so they run once, on Linux.
# - The GPU tests (CPU <-> GPU equivalence, fallback, memory, shutdown) skip when there is no GPU
# adapter. Linux and Windows runners have no GPU: `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` (tests only;
# the app refuses software adapters) lets them use Mesa's lavapipe on Linux and WARP (DX12) on
# Windows. macOS runners expose a Metal device. The GPU step fails if the tests skip anyway
# (`skipped: no GPU adapter`, or `skipped (gpu): ...` from lightcraft-testkit's `skip!`, #1037).
# On macOS and Windows it names the test targets with `--workspace`, which reuses the build of
# the test step (under a minute of building). On Linux, after `cargo xtask ci`, the same command
# rebuilt about 190 crates (7 min), so it runs `lightcraft-gpu`'s tests only (about 2 min).
#
# Standard GitHub-hosted runners only: free for public repositories. Never a larger runner here
# (release.yml's macos-15-xlarge is budget-capped).
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# what `cargo xtask ci` uses: backtraces with line numbers, a fraction of full debuginfo's RAM and cache
CARGO_PROFILE_DEV_DEBUG: line-tables-only
RUST_BACKTRACE: 1
jobs:
linux:
name: Linux x86_64 (xtask ci, GPU tests on lavapipe)
runs-on: ubuntu-latest
timeout-minutes: 90
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Optional font build input, so the Japanese and Chinese font tests run (they skip without it).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Install system libraries and Mesa's Vulkan drivers (lavapipe)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
mesa-vulkan-drivers libvulkan1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
# Dependencies only (rust-cache drops the workspace's own artifacts), saved from main and
# restored by pull requests, so the 10 GB cache holds one entry per job.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ci
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: cargo xtask ci
run: cargo xtask ci
- name: GPU tests on a software adapter
if: ${{ !cancelled() }}
shell: bash
env:
LIGHTCRAFT_GPU_ALLOW_SOFTWARE: "1"
run: |
cargo test -p lightcraft-gpu --locked --tests -- --nocapture 2>&1 | tee "$RUNNER_TEMP/gpu-tests.log"
if grep -qE "skipped( \(gpu\))?: no GPU adapter" "$RUNNER_TEMP/gpu-tests.log"; then
echo "::error::the GPU tests skipped: the runner offered no GPU adapter"
exit 1
fi
desktop:
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- { name: "macOS arm64 (clippy, test, GPU tests on Metal)", runner: macos-latest }
- { name: "Windows x86_64 (clippy, test, GPU tests on WARP)", runner: windows-latest }
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
steps:
# Check files out as committed: tests compare bytes of fixtures and docs.
- name: Keep line endings
run: git config --global core.autocrlf false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ci
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: cargo clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# every failing test binary, not only the first: platform failures tend to come in groups
- name: cargo test
if: ${{ !cancelled() }}
run: cargo test --workspace --locked --no-fail-fast
- name: GPU tests on a software adapter
if: ${{ !cancelled() }}
shell: bash
env:
LIGHTCRAFT_GPU_ALLOW_SOFTWARE: "1"
run: |
cargo test --workspace --locked --test equivalence --test fallback --test memory \
--test shutdown --test shutdown_first -- --nocapture 2>&1 | tee "$RUNNER_TEMP/gpu-tests.log"
if grep -qE "skipped( \(gpu\))?: no GPU adapter" "$RUNNER_TEMP/gpu-tests.log"; then
echo "::error::the GPU tests skipped: the runner offered no GPU adapter"
exit 1
fi
+2 -2
View File
@@ -39,8 +39,8 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
- **`unsafe` lives only in `crates/sysmem`** (two FFI calls: `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes, and `fsync`, for volumes that refuse std's `F_FULLFSYNC`). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
Generated
+4 -2
View File
@@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.1"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
@@ -3104,6 +3104,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-raw",
"lightcraft-sysmem",
"log",
"proptest",
"serde",
@@ -3124,6 +3125,7 @@ dependencies = [
"lightcraft-merge",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-tiff",
"lightcraft-ui-egui",
"serde_json",
]
+537
View File
@@ -0,0 +1,537 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">صورك أنت. بكسلاتك أنت. جهازك أنت.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.ar.md">العربية</a>
</p>
<p align="center">
<b>مكتبة صور وتظهير ملفات raw؛ إعادة تنفيذ مفتوحة المصدر لبرنامج Adobe Lightroom وفق منهجية الغرفة النظيفة، مبنية بلغة Rust خالصة.</b><br>
يعمل أصلياً على macOS وWindows وLinux، وفي المتصفح عبر WebAssembly، ويمكن قيادة أدائه كاملاً عبر وكلاء الذكاء الاصطناعي من خلال MCP.
</p>
<p align="center">
<img alt="Rust خالصة" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
<img alt="macOS · Windows · Linux · الويب" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<img alt="خادم MCP مدمج" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="الترخيص: MIT أو Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="الحالة: فتيّ ويتقدّم بسرعة" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="انضم إلى مجتمع ArtCraft على Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/lightcraft"><b>LightCraft على getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">كل تطبيقات Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/hero-tetons.jpg" alt="واجهة تحرير LightCraft مع صورة أنسل آدمز «تيتونز ونهر الأفعى» في العدسة، ولوحتا الضوء والتأثيرات مفتوحتان على اليمين، وصور العرض الأربع في شريط الفيلم" width="100%">
<br>
<sub><i>أنسل آدمز، «التيتونز ونهر الأفعى» (1942). ملكية عامة، الأرشيف الوطني الأمريكي. مُظهّرة في LightCraft.</i></sub>
</p>
> [!NOTE]
> **ArtCraft مجتمعٌ يجمع الفنانين من كل المشارب.** رقمي، توليدي، موسيقى،
> ألعاب — إن كنت تصنع شيئاً فأنت منّا. **[تعال وقل مرحباً على Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#حرّر-بجدية">التحرير</a> ·
<a href="#معايرة-الألوان-على-الطريقة-السينمائية">معايرة الألوان</a> ·
<a href="#قبل-وبعد">قبل وبعد</a> ·
<a href="#أقنعة-تتبع-إصبعك">الأقنعة</a> ·
<a href="#الإعدادات-المسبقة-والملفات-التعريفية-ومازج-الألوان">الإعدادات المسبقة</a> ·
<a href="#نظّم-كل-شيء">المكتبة</a> ·
<a href="#مبني-لوكلاء-الذكاء-الاصطناعي">الوكلاء وMCP</a> ·
<a href="#سريع-أصلي-خاص">الأداء</a> ·
<a href="#حالة-المزايا">الحالة</a> ·
<a href="#البداية-السريعة">البداية السريعة</a> ·
<a href="ROADMAP.md">خريطة الطريق</a> ·
<a href="#التنزيلات">التنزيلات</a> ·
<a href="#تطبيقات-crafting-apps">تطبيقات Crafting Apps</a>
</p>
<br>
<div dir="rtl">
## حرّر بجدية
LightCraft غرفة تحميم كاملة في تطبيق أصلي واحد. كل تعديل **غير إتلافي**، فأصولك الأصلية لا تُمسّ أبداً. كل منزلق يُصيَّر عبر **مسار عرض مرجعي مشهدي واسع المدى بفاصلة عائمة 32-بت**: الإضاءات المرتفعة تنحدر كالفيلم، والظلال تنفتح بلا هالات ضوئية، ويبقى اللون نقياً من لحظة الالتقاط حتى التصدير.
<table>
<tr>
<td width="50%" valign="top">
### ☀️ الضوء
**التعريض، التباين، الإضاءات المرتفعة، الظلال، الأبيض، الأسود**، مع رسم نبرة موضعي واعٍ بالحواف (مرشح موجَّه على إضاءة لوغاريتمية). سحب الإضاءات المرتفعة إلى ‎−100‎ يستعيد سماءً محترقة دون الهالات الرمادية التي يخلفها منحنى ساذج.
### 🎨 اللون
**موازنة اللون الأبيض** بالحرارة والتدرّج (كلفن لملفات raw، وقيم نسبية لـ JPEG) مع إعدادات مسبقة وخيار تلقائي و**قطارة** تنقر لتحييد اللون. **الاهتمام (Vibrance)** الذي يحمي درجات البشرة، **التشبّع**، **مازج ألوان** بثماني نطاقات (درجة لون / تشبّع / إضاءة) وعجلات **معايرة ألوان** ثلاثية الاتجاه مع مزج وتوازن. يحسب كل ذلك في مساحة OkLCh، مساحة ألوان إدراكية حديثة.
</td>
<td width="50%" valign="top">
### ✨ التأثيرات
**النسج** للتفاصيل الدقيقة، **الوضوح** لقسمة النبرات الوسطى، **إزالة الغباش** (أولوية القناة المظلمة مع تنقية موجّهة؛ ادفعه سالباً لإضافة أجواء)، **تظليل الحواف** بعد القص بأولوية الإضاءات مع استدارة وترييق، و**حبيبات** الفيلم المستقلة عن الدقة مع تحكّم في الحجم والخشونة.
### 📈 منحنى النبرة
منحنى مناطق بارامتري بحواف قابلة للنقل **بالإضافة إلى** منحنيات نقطية لـ RGB والأحمر والأخضر والأزرق. المنحنيات أحادية الاتجاه بحكم بنيتها، فلا يحدث انقلاب نبرة بالمحاولة.
</td>
</tr>
</table>
<p align="center">
<img src="docs/images/curve-tetons.jpg" alt="منحنى النبرة مفتوح تحت لوحة الضوء، مع منحنى نقطي RGB بشكل S خفيف مطبق على التيتونز ونهر الأفعى" width="100%">
<br>
<sub>انحناءة S خفيفة على منحنى RGB النقطي، تحت منزلقات الضوء مباشرة. <i>أنسل آدمز، 1942 (ملكية عامة).</i></sub>
</p>
<br>
## معايرة الألوان على الطريقة السينمائية
فاصِل نبرة الظلال والنبرات الوسطى والإضاءات المرتفعة كلٌّ على حدة بعجلات ألوان تسحب في أي اتجاه. أدناه، تحصل صورة دوروثيا لانج *الأم المهاجرة* على نبرة دافئة تشبه المطبوعات (الإضاءات عند 42°، والظلال عند 28°) بسحبتين اثنتين.
<p align="center">
<img src="docs/images/grading-migrant-mother.jpg" alt="عجلات معايرة الألوان للنبرات الوسطى والظلال والإضاءات بجانب صورة الأم المهاجرة لدوروثيا لانج، بنبرة دافئة كالمطبوعات" width="100%">
<br>
<sub>عجلات الظلال والنبرات الوسطى والإضاءات في لوحة اللون. <i>دوروثيا لانج، «الأم المهاجرة» (1936). ملكية عامة، مكتبة الكونغرس.</i></sub>
</p>
<br>
## قبل وبعد
استمر بالضغط على <kbd>\\</kbd> لالتقاط نظرة على الأصل، أو اضغط <kbd>Y</kbd> لعرض متجاور، أو <kbd>Shift</kbd>+<kbd>Y</kbd> لعرض مقسوم. كل صورة أدناه لقطة شاشة حقيقية من LightCraft، التقطها وكيل ذكاء اصطناعي تلقائياً عبر [قناة التحكم](#مبني-للوكلاء).
<table>
<tr>
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="قبل وبعد متجاوران للتيتونز ونهر الأفعى: النتيجة بسحب أعمق وظلال أكثر انفتاحاً على ضفاف النهر"><br><sub><b>التيتونز ونهر الأفعى.</b> الإضاءات المرتفعة ‎−45‎، الظلال ‎+38‎، الوضوح ‎+28‎، إزالة الغباش ‎+18‎. <i>أنسل آدمز، 1942 (ملكية عامة).</i></sub></td>
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="قبل وبعد متجاوران للأم المهاجرة: النتيجة أدفأ مع ظلال مرفوعة"><br><sub><b>الأم المهاجرة.</b> الظلال ‎+42‎، النسج ‎+18‎، فصل نبرة، تظليل حواف. <i>دوروثيا لانج، 1936 (ملكية عامة).</i></sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="قبل وبعد متجاوران لصورة شروق الأرض: الأرض فوق أفق القمر، والنتيجة أدفأ وأغنى قليلاً"><br><sub><b>شروق الأرض.</b> إزالة الغباش ‎+22‎، الإضاءات المرتفعة ‎−30‎، موازنة أبيض أدفأ، الاهتمام ‎+22‎. <i>ناسا / بيل أندرس، أبولو 8، 1968 (ملكية عامة).</i></sub></td>
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="قبل وبعد متجاوران للرخامة الزرقاء: النتيجة بأسود أعمق وتفاصيل سحب أمتن"><br><sub><b>الرخامة الزرقاء.</b> الإضاءات المرتفعة ‎−38‎، الأسود ‎−20‎، إزالة الغباش ‎+15‎، الاهتمام ‎+30‎. <i>ناسا، أبولو 17، 1972 (ملكية عامة).</i></sub></td>
</tr>
</table>
<br>
## أقنعة تتبع إصبعك
ارسم بـ **فرشاة** (حجم، ترييق، تدفق، كثافة، ممحاة)، أو أنزِل **تدرجات خطية** و**شعاعية** بدبابيس قابلة للسحب، أو حدّد بـ **نطاق الإضاءة**، **نطاق اللون**، **السماء**، **الموضوع** و**الخلفية**. ادمج المكونات بـ **إضافة / طرح / تقاطع**، اعكس أيّاً منها، واضبط 15 تعديلاً موضعياً لكل قناع (الحرارة، التدرّج، التعريض، التباين، الإضاءات المرتفعة، الظلال، الأبيض، الأسود، النسج، الوضوح، إزالة الغباش، درجة اللون، التشبّع، الحدة، الضوضاء) إضافة إلى مقدار إجمالي شامل.
<p align="center">
<img src="docs/images/masking.jpg" alt="لوحة الأقنعة مع قناع سماء خطي وقناع توهج شمس شعاعي؛ التدرج الشعاعي مرسوم كطبقة حمراء حول الشمس في مشهد بحيرة" width="100%">
<br>
<sub>قناع «توهج الشمس» الشعاعي (الحرارة ‎+40‎، التعريض ‎+0.50‎) فوق قناع «السماء» الخطي. <i>صورة من مكتبة العرض التوليدية في LightCraft.</i></sub>
</p>
<br>
## الإعدادات المسبقة والملفات التعريفية ومازج الألوان
ثمانية عشر إعداداً مسبقاً مصنوعاً بعناية في الصندوق (*الساعة الذهبية، الأزرق الداكن والبرتقالي، المطفي الباهت، نبرة السيلينيوم، مشهد حاد* وغيرها)، لكلٍّ منها منزلق **مقدار** من 0 إلى 200٪. احفظ إعداداتك الخاصة من أي مجموعة إعدادات، وعلّم المفضلة، وانسخ التعديلات أو الصقها أوزامنها عبر تحديد كامل بالمجموعات التي تختارها بالضبط.
<p align="center">
<img src="docs/images/presets-mixer.jpg" alt="عمود الإعدادات المسبقة مقسّماً إلى أبيض وأسود، ألوان، فيلم، مشاهد، بورتريه، ونمط، بجانب لوحة اللون مع مازج الألوان ثماني النطاقات مفتوحاً على البنفسجي" width="100%">
<br>
<sub>عمود الإعدادات المسبقة بجانب مازج الألوان ثماني النطاقات. <i>صورة من مكتبة العرض.</i></sub>
</p>
<table>
<tr>
<td width="50%" valign="top">
### ✂️ القص والتعديل والهندسة
نِسَب عرض حرة أو مقفلة (1:1، 4:5، 5:7، 2:3، 4:3، 16:9، 16:10، الأصلية)، تعديل بالسحب للتدوير يحافظ دائماً على أكبر قص داخل الصورة، شبكات الأثلاث والشبكة والنسبة الذهبية، انعكاسات وتدويرات 90°، إضافة إلى تحويلات رأسية وأفقية وتدوير ونسبة وحجم وإزاحة يدوية.
</td>
<td width="50%" valign="top">
### ⚫️ الأبيض والأسود
نقرة واحدة (<kbd>V</kbd>) إلى أحادي اللون، مع **مازج أبيض وأسود** ثماني النطاقات يتيح لك تعتيم السماء أو إشعاع أوراق الشجر. ثم امنحه نبرة بمعايرة الألوان للحصول على مطبوعات سيلينيوم أو سيبيا أو فاصلة النبرة.
</td>
</tr>
<tr>
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="أداة القص على التيتونز ونهر الأفعى: قص 16×9 مُدار قليلاً، مع شبكة أثلاث ومنزلقات الهندسة على اليمين"><br><sub>قص 16:9 مُعدَّل التدوير ‎+2.5°‎ مع شبكة الأثلاث. <i>أنسل آدمز، 1942 (ملكية عامة).</i></sub></td>
<td width="50%"><img src="docs/images/bw-split.jpg" alt="عرض مقسوم قبل وبعد لكثبان رملية: اللون على اليسار، والأبيض والأسود بحبيبات وتظليل حواف على اليمين"><br><sub>عرض مقسوم: الأصل ملوّن يساراً، وأبيض وأسود بالوضوح وتظليل الحواف والحبيبات يميناً. <i>مكتبة العرض.</i></sub></td>
</tr>
</table>
<br>
## نظّم كل شيء
مكتبة لا تعترض طريقك: **كل الصور**، **أُضيفت حديثاً**، **المختارات**، **حسب التاريخ**، **تجميعات** متداخلة في **مجلدات**، و**المحذوفات الأخيرة**. قيّم بـ <kbd>0</kbd>–<kbd>5</kbd>، علّم بـ <kbd>P</kbd> / <kbd>X</kbd> / <kbd>U</kbd>، وألصق تسميات لونية بـ <kbd>6</kbd>–<kbd>9</kbd>. البحث يفهم الحقول:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. كل عرض يُرتَّب حسب تاريخ الالتقاط أو الاستيراد أو التعديل أو الاسم أو التقييم أو الحجم أو عشوائياً (خلطة ثابتة؛ عرض → ترتيب → إعادة خلط للحصول على خلطة جديدة). عرضا **شبكة الصور** المبررة و**الشبكة المربعة** افتراضيان، فيبقيان سلسين سواء كان لديك أربعون صورة أم أربعون ألفاً.
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="شبكة صور مبررة بـ 24 صورة عرض مع تقييمات وأعلام، وشريط جانبي بتجميعات متداخلة تحت مجلد سفر 2026"><br><sub>شبكة الصور مع تجميعات متداخلة في مجلد، مع تقييمات وأعلام. <i>مكتبة العرض.</i></sub></td>
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="شبكة مربعة تعرض صور العرض الأربع ملكية العامة مع تقييمات نجمية وأعلام اختيار"><br><sub>شبكة مربعة لصور العرض الأربع ملكية العامة.</sub></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="لوحة المعلومات لصورة شروق الأرض تعرض اسم الملف وأبعاد JPEG ‏2400×2400 والتقييم وحقل العنوان وبيانات الكاميرا"><br><sub>لوحة المعلومات: الملف والأبعاد والتقييم والعنوان والتسمية التوضيحية وحقوق النشر وبيانات الكاميرا. <i>ناسا / بيل أندرس، «شروق الأرض»، أبولو 8، 1968 (ملكية عامة).</i></sub></td>
</tr>
</table>
<br>
## مبني لوكلاء الذكاء الاصطناعي
كل عنصر قائمة ومنزلق وضربة فرشاة ومقبض قص وضغطة مفتاح في LightCraft هو **أمر** بمعرّف ثابت ومعاملات JSON. الواجهة ولوحة المفاتيح وواجهة سطر الأوامر وقناة تحكم JSON-lines و**خادم MCP** كلها تُوزَّع عبر نقطة الدخول ذاتها. يستطيع الوكيل غربلة جلسة تصوير وتظهيرها وتقنيع سماء وتصديرها، ثم *رؤية* النتيجة.
```sh
lightcraft --control 7980 ~/Pictures/trip
```
```jsonc
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // قُد أي عنصر بالاسم
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
{"method": "ui.screenshot", "params": {"path": "after.png"}}
```
- **سجل الأوامر.** يعرض `engine.commands` الأوامر المتاحة؛ ويعرض `develop.controls` نطاق كل منزلق وقيمته الافتراضية والحالية.
- **خادم MCP.** يعرض `lightcraft-cli mcp` سجل الأوامر لـ Claude (أو أي عميل MCP)، إلى جانب مساعدات للاستيراد والاستعلام والتظهير والتقنيع والتصيير (يُعاد كصورة) والتصدير. يعمل بلا واجهة أو متصلاً بالتطبيق الجاري بلقطات شاشة ونقرات وإيماءات. انظر [docs/mcp.md](docs/mcp.md).
```sh
cargo build --release -p lightcraft-cli
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # بلا واجهة
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # التطبيق الحي
```
- **واجهة سطر قابلة للبرمجة:** يُشغّل `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export path=out.jpg longEdge=2048` أياً كان تسلسل الأوامر (بلا واجهة، أو على مكتبة محفوظة، أو ضد التطبيق الجاري) ويطبع نتيجة JSON واحدة لكل أمر؛ كذلك `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
- **تراجع عن كل شيء**، بما في ذلك أفعال الوكلاء: سحب منزلق (أو دفعة تحديثات مبرمجة) خطوة تراجع واحدة.
- **أوراق الاتصال (التطبيق الأصلي / واجهة السطر):** حدّد الصور ثم **ملف → ورقة اتصال PDF…**. اختر A4 أو Letter، وأفقيّاً، وعدد الصفوف/الأعمدة وتسميات أسماء الملفات؛ واحفظ PDF مرقّم الصفحات واطبعه من عارض PDF لديك. تستخدم التصديرات التعديلات الحالية، وتلائم كل صورة دون قص إضافي، وتصيَّر بدقة 150 نقطة/بوصة في sRGB. يدعم التصدير في الخلفية الإلغاء ويحمي الأصول الأصلية وملفات XMP المرافقة. من واجهة السطر:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
يقبل `export.contactSheet` أيضاً `ids` صريحة، و`landscape=true`، و`paper=letter`. ويستبدل المخرجَ الموجود فقط بعد نجاح كامل ملف PDF. الحدود: 1,000 صورة، و100 صفحة، و256 ميبيبايت؛ تُرسَم التسميات بتغطية الخطوط ذاتها المستخدمة للعلامات المائية (الصينية واليابانية تتطلبان craft-fonts)، مع تقصير الأسماء الطويلة من المنتصف. هذا سير عمل أوراق اتصال،
لا محرر تخطيطات طباعة أو حزم صور مخصصة.
- **كل عنصر قابل للعنونة** (`ui.widgets`) وقابل للنقر بالاسم، فيشغّل الوكلاء الواجهة الحقيقية لا باباً جانبياً.
- لقطات الشاشة في هذا الملف أُنتجت كاملةً بسكربتات [`docs/showcase/`](docs/showcase/).
مرجع البروتوكول: [docs/control-protocol.md](docs/control-protocol.md).
<br>
## سريع، أصلي، خاص
- **Rust خالصة، لا C.** فواك raw خاصة بنا (DNG، وCanon CR2/CR3، وSony ARW، وNikon NEF، وFujifilm RAF بما فيها X-Trans، وPanasonic RW2 / Leica RWL، وPentax PEF، وOlympus ORF)، وعلم ألوان خاص بنا، ومسار خاص بنا. تفتح JPEG وPNG وTIFF وWebP وتركيبات PSD وJPEG XL اليوم؛ وصور HEIC/HEIF أيضاً مع `--features heif`‏ (‏HEVC خيار وقت البناء، كما في PhotoCraft).
- **مرجعي مشهدي وواسع المدى.** داخلياً: Rec.2020 خطية بفاصلة عائمة، وموازنة بيضاء بتكيّف برادفورد، وتخطيط نطاق بدلاً من القص، وكتف فيلمي لملفات raw وعبور مطابق للبكسل لملفات JPEG التي لم تلمسها.
- **تعديلات مستقلة عن الدقة.** أنصاف الأقطار وأحجام الفرش نسبية إلى الصورة، فتبدو معاينة 400 بكسل وشاشتك 5K وتصدير 60 ميغابكسل واحداً سواء.
- **تسارع GPU مع دقة CPU.** يعمل مسار التظهير كله كانوية حساب wgpu ‏(Metal / Vulkan / DX12)، مطابَقاً لمسار CPU إلى حد 1/255. على ملف raw بدقة 24 ميغابكسل (Apple M4 Pro): إعادة تصيير تحديث منزلق تستغرق ~4 ملّي ثانية، وعدسة 2.5 ميغابكسل باردة ~30 ملّي ثانية، وتصدير كامل الحجم ~0.3 ثانية شاملاً ترميز JPEG متوازٍ.
من دون GPU يعمل المسار نفسه على كل أنوية CPU، معيداً حساب المراحل التي يمسّها المنزلق فقط. تصديرات الدفعات تصيّر عدة صور جنباً إلى جنب، فيتراكب فك ترميز إحداها وترميزها مع تصيير أخرى.
- **غربلة فورية.** فتح ملف raw يعرض معاينته الكاميرا المدمجة أو تصييره المخزّن خلال ~0.1 ثانية بينما يتبع التصيير الكامل (~0.2–0.5 ثانية لـ 24 ميغابكسل). تُجهَّز الصورتان التالية والسابقة في الخلفية، فتستغرق الإدارة عبر جلسة التصوير ~50 ملّي ثانية للصورة.
- **تصيير في الخلفية.** مجموعة عمال تصيّر العدسة و«قبل/بعد» وكل مصغّر مرئي خارج خيط الواجهة: مسودات أثناء السحب وجودة كاملة عند الإفلات.
- **محلي أولاً.** لا حساب، لا سحابة، لا قياس عن بعد، لا اشتراك. الفهرس الخاص بك سجل عمليات مقروءة بالحرف لا يُمسح منه شيء، يمكنك فروقه أو نسخه احتياطياً أو إعادة تشغيله.
<br>
## حالة المزايا
LightCraft فتيّ ويتقدّم بسرعة: **ألفا**. **أين نقف بصدق** (التفاصيل في [خريطة الطريق](ROADMAP.md) و[docs/target-app-parity.md](docs/target-app-parity.md)):
- **بعدد المزايا بلغنا ~81٪** من Lightroom ‏(المزايا الجوهرية 98٪)، متتبعين صفاً صفاً في
[docs/parity-checklist.md](docs/parity-checklist.md).
- **كبديل يومي عن Lightroom نحن أقرب إلى 61٪ للمنتج كاملاً، و~49٪ لمصوّر محترف نموذجي، و~64٪ للاستخدام العابر.** التطبيق ممتاز مع JPEG/DNG ومعظم ملفات raw من Sony وPanasonic و
Fujifilm وNikon وCanon الأقدم على جهاز واحد؛ انظر [docs/raw-parity.md](docs/raw-parity.md) لكاميراك.
- **أكبر الفجوات:**
- **معايرة ألوان الكاميرات:** ملفات raw من Sony وNikon وPanasonic وFujifilm وCanon CR3 تستخدم تقديرات محروسة من JPEG كاميراتها، مع ملفات تعريفية مدمجة لـ ILCE-7CR وILCE-7M4 وX-H2S وX-T4؛ المعايرة المقيسة غائبة، وتبقى ملفات raw الأخرى أو المطابقات المرفوضة على مصفوفة محايدة؛
- **ملفات raw المضغوطة من Olympus ومتغيرات CR3 غير المدعومة:** تستخدم معاينات JPEG المدمجة إن وُجدت. ملفات RAF المضغوطة (بلا فقد / بفقد) من Fujifilm تفك بيانات المستشعر الآن؛ [تعليمات التحقق وإعادة تحميل المكتبات القائمة](docs/raf-compression.md)؛
- **أقنعة AI وإزالة الضوضاء:** تحديد الموضوع والسماء قواعد استدلالية تقليدية؛
- **مكتبات الشبكة:** المعاينة تقرأ ترويسة ملف raw ومعاينة JPEG المدمجة فقط ‏(‏56.5 ميغابايت → 5.9 ميغابايت، و10 ثوانٍ → ثانية على مشاركة الاختبار)، لكن الصورة *المُظهّرة* ما زالت تحتاج بيانات المستشعر عبر الشبكة؛ انظر [docs/raw-preview-reads.md](docs/raw-preview-reads.md)؛
- **عرض HDR على الشاشة، والفيديو، ووحدات Classic ‏Print / Book / Map.**
- **ما التالي:** انظر [الفجوات مرتبة](docs/gaps.md) و[خريطة الطريق](docs/roadmap.md).
| المجال | الحالة |
|---|---|
| المكتبة: تجميعات، مجلدات، تجميعات ذكية، رزم (بما فيها الرزم التلقائية)، نسخ افتراضية، تقييمات، أعلام، تسميات، شريط ترشيح، بحث، ترتيب، شبكات، شريط فيلم | ✅ |
| الغربلة: عرضا المقارنة (تكبير متزامن) والاستعراض، تقدّم تلقائي، معاينات فورية | ✅ |
| الضوء واللون والتأثيرات (أنماط تظليل الحواف)، منحنى النبرة (+ تنقية التشبّع والتعديل الموجّه)، مازج الألوان (+ موجّه)، لون نقطي، معايرة الألوان، أبيض وأسود | ✅ |
| الأقنعة: فرشاة، خطي، شعاعي، نطاق إضاءة/لون، إضافة/طرح/تقاطع | ✅ (موضوع/سماء AI بقواعد استدلالية تقليدية حالياً) |
| القص، أداة التعديل + تعديل تلقائي، انعكاس، تدوير، نسب عرض، شبكات | ✅ |
| ملفات تعريفية (ألوان، محايد، حيوي، مشاهد، بورتريه، أحادي: مظاهر من صنعنا)، إعدادات مسبقة، إصدارات، سجل، نسخ/لصق/مزامنة | ✅ |
| ألوان الكاميرات: ملفات DNG تستخدم مصفوفاتها الخاصة | ✅ DNG · 🚧 ملفات Sony/Fujifirm خاصة بنا؛ قاعدة معايرة مقيسة غائبة |
| شريط قوائم macOS الأصلي (مولَّد من سجل الأوامر)، قناة تحكم + كل عنصر قابل للعنونة، لقطات واجهة بلا واجهة | ✅ |
| RAW: DNG، CR2، CR3 (‏CRX باير بلا فقد وC-RAW إصدار 0x100/0x200)، ARW، NEF (غير مضغوط + مضغوط بلا/بفقد، بما فيه المضغوط بفقد بعد الفصل)، Fujifilm RAF (غير مضغوط + مضغوط بلا/بفقد، باير + X-Trans)، Panasonic RW2 / Leica RWL / Panasonic RAW (كل صيغ raw، من DMC-LX1 إلى DC-S1RM2)، Pentax PEF، Olympus ORF (غير مضغوط، 12.8 بت/بكسل)، Samsung SRW (غير مضغوط)؛ معاينات مدمجة لكل صيغة بما فيها CR3 | 🚧 · متغيرات CR3 الأخرى، وORF / SRW المضغوطة وNikon HE NEF ⬜ ([تعادل raw](docs/raw-parity.md)) |
| التفاصيل: الحدة، خفض ضوضاء الإضاءة + اللون | ✅ · إزالة ضوضاء AI 🚧 (باير، أوزان يثبّتها المستخدم)، الدقة الفائقة ⬜ |
| إزالة / مداواة / استنساخ البقع (مصدر تلقائي)، تصوّر البقع، عين حمراء وعين حيوانات (اكتشاف حدقة تلقائي، لمعة عين) | ✅ · تعبئة واعية بالمحتوى، تحرير دبابيس البقع 🚧 |
| التصدير: JPEG / PNG / TIFF / WebP / AVIF / DNG / الأصل، تحجيم، حد حجم ملف، حدة إخراج، قوالب تسمية، دفعات، سياسة بيانات وصفية، علامة مائية نصية أو صورية | ✅ |
| HDR: تحرير HDR مع سقف مساحة، مسودة SDR، تصوّر HDR؛ تصدير كخريطة كسب ISO 21496-1 JPEG أو PQ AVIF أو TIFF فاصلة عائمة 32-بت | ✅ · عرض HDR ⬜ |
| ثبات المكتبة (سجل عمليات آمن ضد الانهيار + لقطات، ضغط في الخلفية، إبلاغ عن عمليات الحفظ الفاشلة)، ذاكرة مصغرات على القرص | ✅ |
| الاستيراد: إضافة في مكانها / نسخ / نقل، قوالب إعادة تسمية ومجلدات، أجهزة، كشف التكرارات، مجلدات مراقَبة؛ استعراض مجلد محلي | ✅ |
| خادم MCP (بلا واجهة أو تطبيق حي، مكتبات ثابتة)، واجهة سطر، قناة تحكم | ✅ |
| ملفات XMP مرافقة (قراءة/كتابة، كتابة تلقائية)، قراءة إعدادات تظهير `crs:`، ملفات إعدادات مسبقة (`.lcpreset`، إعدادات XMP مسبقة) | ✅ |
| بصريات (تشويه، تظليل حواف، زغب كروماتي تلقائي + يدوي، إزالة الحواف الملوّنة، تصحيحات عدسات مدمجة في DNG وبيانات تشويه Panasonic / Leica RW2 / RWL)، هندسة (تحويلات، قص مقيَّد)، Upright ‏(تلقائي/مستوٍ/رأسي/كامل/موجَّه) | ✅ · ملفات تعريفية لعدسات الكاميرات (من صنعنا) ⬜ |
| دمج الصور: HDR (محاذاة تلقائية، إزالة الأشباح)، بانوراما (كروية/أسطوانية/منظورية، انبعاج الحواف، قص تلقائي)، بانوراما HDR ← DNG | ✅ |
| مسار GPU (حساب wgpu، مطابق لـ CPU إلى 1/255)، احتياط CPU عند حدود/أخطاء الجهاز | ✅ · WebGPU في المتصفح 🚧 |
| AI: أقنعة تجزئة، إزالة ضوضاء AI، دقة فائقة، وجوه؛ فيديو | 🚧 (انظر [الفجوات](docs/gaps.md)) |
| بناء الويب (الواجهة نفسها في المتصفح عبر WASM): مكتبة ثابتة في OPFS/IndexedDB، تصيير في Web Worker، تنزيل المخرجات | ✅ · WebGPU، واختبار Safari/Firefox 🚧 |
<sub>✅ يعمل اليوم · 🚧 جارٍ العمل عليه · ⬜ لم يبدأ</sub>
<br>
## البداية السريعة
بناء مساحة العمل، بما فيها تطبيقات سطح المكتب والويب وواجهة السطر، يتطلب Rust ‏1.95 أو أحدث.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped)
cargo run --release -p lightcraft -- --memory # a throwaway in-memory demo session (writes nothing)
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
```
تقصّ CI افتراضياً معلومات تصحيح جداول الأسطر ووظيفة بناء واحدة لكل 1.5 ميغابايت ذاكرة
متاحة عند بدئه (واحدة لكل معالج على الأكثر؛ و4 إن تعذّرت قراءة الذاكرة)، فجهاز
بذاكرة 8 ميغابايت فيها 6 متاحة يبني بـ 4 وظائف، والجهاز المزدحم قليل الفضاء يبني
بأقل. تتبع خيوط الاختباعات ذلك بحد أقصى 4. روابط معلومات التصحيح الكاملة، واحد
لكل معالج، تستنفد الذاكرة قبل تشغيل أي اختبار وإلا. تغطية GPU كما هي. تتجاوز
إعدادات `CARGO_PROFILE_DEV_DEBUG` و`CARGO_BUILD_JOBS` و`RUST_TEST_THREADS` الصريحة
هذه الافتراضيات. أوامر التطوير المعتادة تحتفظ بإعداداتها الخاصة.
**النصوص العربية والصينية واليابانية** تحتاج مستودع الخطوط المشترك، وهو مدخل بناء اختياري (الإصدارات الرسمية تتضمنه دائماً):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
من دونه يُبنى LightCraft ويعمل سواء بسواء، لكن النصوص العربية والصينية واليابانية بلا محارف. لا تُودَع الخطوط أبداً في هذا
المستودع — معيار ما يدخل هناك يعيش معه: انظر [README‏ craft-fonts](https://github.com/storytold/craft-fonts#readme).
**لغة الواجهة:** **تحرير → لغة** (English، 简体中文, 繁體中文(台灣), 日本語، Português (Brasil)، Español، Deutsch، Русский، Français، العربية) أو **إعدادات → عام →
لغة**؛ يُطبَّق الاختيار فوراً ويُحفظ. انظر [docs/localization.md](docs/localization.md).
**السجلات:** يكتب تطبيق سطح المكتب سجله إلى الخرج القياسي وإلى `logs/lightcraft.log` في مجلد إعداداته
‏(‏Linux: `$XDG_CONFIG_HOME/lightcraft/logs/` افتراضياً `~/.config/lightcraft/logs/`؛ وmacOS:
`~/Library/Application Support/LightCraft/logs/`؛ وWindows: `%APPDATA%\LightCraft\logs\`)، ولا يكتب شيئاً في المكتبة أبداً. إطلاق
من قائمة سطح المكتب أو Dock بلا طرفية، فأرفق هذا الملف بتقرير الخطأ (**مساعدة ▸ فتح مجلد السجلات**
يعرضه في مدير الملفات). كل تشغيل ينقل السجل السابق إلى
`lightcraft.1.log` وذاك إلى `lightcraft.2.log`، فينجو سجل جلسة انهارت من
التشغيل التالي؛ يتوقف الملف عن النمو عند 16 ميغابايت، ولا يكتب `--version` و`--help` شيئاً، والجلسات
بـ `LIGHTCRAFT_NO_PREFS` تسجل إلى الخرج القياسي فقط. افتراضياً تسجل صناديق LightCraft نفسها بمستوى `info` وكل ما عداها
بمستوى `warn`. يعمل `LIGHTCRAFT_LOG=info` أو `debug` كالسابق (ذاك المستوى لصناديق LightCraft نفسها، وتحذيرات
وأخطاء من البقية؛ أي قيمة أخرى: تحذيرات وأخطاء فقط) ويتقدم على `RUST_LOG`، الذي يستبدل الافتراضي وإلا بتوجيهات بنمط env_logger مثل
`RUST_LOG=debug` أو `RUST_LOG=warn,lightcraft_pipeline=trace` (توجيه ينتهي بـ
`*` يغطي كل هدف يبدأ به، كما في `lightcraft*=debug`). تُسجَّل حالات الهلع (panics) هناك
أيضاً، وتبقى في `lightcraft-panics.log` في مجلد المؤقتات. المسجِّل هو `apps/lightcraft/src/logging.rs`.
يحتاج بناء الويب الهدف `wasm32-unknown-unknown` وأداة `wasm-bindgen` CLI المطابقة
‏(‏`cargo xtask web` تطبع أمر التثبيت الدقيق)؛ انظر [docs/web.md](docs/web.md).
يبني **Nix** تطبيق سطح المكتب و`lightcraft-cli` (بناء Nix يتضمن دائماً مدخل craft-fonts، فالنصوص
اليابانية لها محارف):
```sh
nix run github:storytold/lightcraft # the desktop app
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
```
في إعداد flake ‏(‏NixOS، home-manager، nix-darwin):
```nix
# flake.nix
inputs.lightcraft.url = "github:storytold/lightcraft";
# optional: build against your own nixpkgs instead of the one LightCraft pins
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
# then, in a NixOS or home-manager module (where `inputs` is in scope):
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
```
يثبّت `nix build` ملف سطح المكتب ذاته وأيقونات hicolor وبيانات AppStream مثل ‎.deb/.rpm، ويشغّل
`cargo test --workspace` في مرحلة التحقق (تخطَّها بـ `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
**لوحة المفاتيح:** <kbd>G</kbd> الشبكة · <kbd>D</kbd> التفاصيل · <kbd>E</kbd> التحرير · <kbd>C</kbd> القص · <kbd>M</kbd> الأقنعة ·
<kbd>Shift</kbd>+<kbd>P</kbd> الإعدادات المسبقة · <kbd>\\</kbd> الأصل · <kbd>Y</kbd> قبل/بعد · <kbd>Z</kbd> تكبير ·
<kbd>J</kbd> اقتصاص · <kbd>⌘Z</kbd> تراجع · <kbd>⌘/</kbd> كل الاختصارات.
## كيف يُبنى
مساحة عمل Cargo محورية حول المحرك، من صناديق صغيرة مختبَرة مع فرض طبقات (`cargo xtask layers`): ‏`geom`،
`color`، `raster`، `tiff` → `raw`، `codecs`، `meta`، `develop` → `pipeline` → `catalog` → `engine` → `ui-egui`.
واجهة egui صندوق قابل للتبديل؛ لا شيء تحته يعلم بوجود واجهة. يعلو `mcp` والتطبيقات ‏(`lightcraft`،
`lightcraft-cli`) فوق `engine`.
## المساهمة
البشر والوكلاء يتبعون القواعد نفسها، فاقرأ [AGENTS.md](AGENTS.md) أولاً. باختصار:
- **الغرفة النظيفة.** لا تقرأ أبداً ملفات Adobe الثنائية أو شيفرة raw/صور بترخيص GPL ‏(‏darktable، RawTherapee، LibRaw، rawspeed، dcraw…)؛
اعمل من المواصفات العامة والملاحظة الصندوقية السوداء.
- **لا أصول Adobe إطلاقاً:** لا أيقونات أو لقطات شاشة أو إعدادات مسبقة أو ملفات تعريفية أو LUTs أو خطوط من منتجات Adobe. كل
صورة وأيقونة وخط في المستودع أصلية أو ملكية عامة أو بترخيص Creative Commons أو OFL أو ترخيص متساهل، ولها
مدخل في [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) يضاف في الالتزام نفسه. الخطوط الجديدة تذهب إلى
[storytold/craft-fonts](https://github.com/storytold/craft-fonts)، لا إلى هنا.
- **Rust خالصة**، فرض طبقات الصناديق، كل شيء أمر، و`cargo xtask ci` أخضر قبل كل التزام
(معرّف مهمة واحد لكل التزام).
- **لا انهيار أبداً.** تُعيد الشيفرة غير الاختبارية أخطاءً بدل الهلع: لا `unwrap()` ولا `expect()` ولا
`panic!` ولا `unsafe`، وفهرسة مفحوصة لأي شيء مشتق من مدخلات، واختبار انحدار مع كل إصلاح انهيار. التفاصيل في
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
أسئلة أو أفكار أو خطأ تحب مناقشته أولاً؟ اطرحها على [Discord](https://discord.gg/artcraft).
<br>
## التنزيلات
**جديد على LightCraft؟** نزّله من [صفحة LightCraft على getartcraft.com](https://getartcraft.com/apps/lightcraft). تلك أسهل طريقة للتثبيت.
**تريد بناءً أو صيغة بعينها؟** على GitHub، [آخر إصدار](https://github.com/storytold/lightcraft/releases/latest) يعرض كل البناءات أدناه، و[كل الإصدارات](https://github.com/storytold/lightcraft/releases) تعرض الإصدارات السابقة وملاحظاتها. `<ver>` في أسماء الملفات هو رقم الإصدار، و`SHA256SUMS.txt` يسرد بصمة تحقق لكل ملف.
### Windows
| البناء | المثبِّت | المحمول |
|---|---|---|
| x64 ‏(‏64-بت Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
| arm64 ‏(‏Snapdragon وحواسيب ARM الأخرى) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86 ‏(‏32-بت) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
المثبِّتات والملفات التنفيذية موقّعة شيفرياً. يسأل المثبِّت عن مكان التثبيت (`C:\Program Files\LightCraft`
افتراضياً؛ الترقيات تبقى في المجلد الذي اخترته) وينتهي بصفحة تؤكد تثبيت LightCraft مع خيار
تشغيله. للتثبيت غير المراقَب: `msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
### macOS
| البناء | الملف | ملاحظات |
|---|---|---|
| التطبيق، شامل (سيليكون Apple + Intel) | `lightcraft-<ver>-macos-universal.dmg` | موقّع وموثّق لدى Apple |
| أداة سطر الأوامر، شامل | `lightcraft-cli-<ver>-macos-universal.zip` | موقّعة وموثّقة لدى Apple |
### Linux
| الصيغة | x86_64 | aarch64 ‏(‏ARM64) | ملاحظات |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | يعمل في أي مكان؛ يحدّث نفسه عبر [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (ملفات `.zsync`) |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | معزول؛ `flatpak install --user <file>` |
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | فُكّ في أي مكان |
‏RISC-V ‏(‏riscv64): ‏`lightcraft-<ver>-linux-riscv64.tar.gz`، حزمة tar فقط (‏glibc ‏2.39 أو أحدث، مثل Ubuntu ‏24.04+).
على سطح مكتب Wayland يعمل LightCraft عبر XWayland إن توفر، لأن X11 وحده يمنح النوافذ
سحب ملفات وإفلاتها؛ عيّن `LIGHTCRAFT_WAYLAND=1` لاستخدام Wayland أصلي (تحجيم كسري أحد، دون سحب).
### FreeBSD
| البناء | الملف |
|---|---|
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
### الويب (‏WebAssembly)
| البناء | الملف | ملاحظات |
|---|---|---|
| موقع ثابت | `lightcraft-web-<ver>.zip` | يعمل في متصفح حديث؛ استضِفه على أي خادم ثابت |
<br>
## تطبيقات Crafting Apps
LightCraft أحد **تطبيقات Crafting Apps**: أدوات إبداعية حرة مفتوحة المصدر من فريق
[ArtCraft](https://getartcraft.com/)، كلٌّ مكتوب من الصفر بـ Rust وقادر على الوقوف وحده.
| | التطبيق | وظيفته | الشيفرة | اعرف أكثر |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | تحرير الصور: طبقات وأقنعة وكتابة وملفات PSD حقيقية | [GitHub](https://github.com/storytold/photocraft) | [الموقع](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | الرسم المتجهي | [GitHub](https://github.com/storytold/vectorcraft) | [الموقع](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | تحرير الفيديو واللون والصوت | [GitHub](https://github.com/storytold/filmcraft) | [الموقع](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **مكتبة الصور وتظهير raw · أنت هنا** | [GitHub](https://github.com/storytold/lightcraft) | [الموقع](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | قراءة ملفات PDF وتنظيمها وحمايتها | [GitHub](https://github.com/storytold/pdfcraft) | [الموقع](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | الرسوم المتحركة والمؤثرات البصرية | [GitHub](https://github.com/storytold/effectcraft) | [الموقع](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | تخطيط الصفحات والنشر | [GitHub](https://github.com/storytold/designcraft) | [الموقع](https://getartcraft.com/apps/designcraft) |
و[**ArtCraft**](https://getartcraft.com/) نفسه، استوديو صور وفيديو بالذكاء الاصطناعي للفنانين الذين يريدون تحكماً حقيقياً.
## الترخيص والإسنادات
LightCraft مرخّص مزدوجاً بموجب [MIT](LICENSE-MIT) أو [Apache-2.0](LICENSE-APACHE)، بحسب اختيارك.
حقوق النشر (c) 2026 لفريق ArtCraft ومساهمي LightCraft. الإشعارات المطلوبة في [NOTICE](NOTICE).
الخطوط والأيقونات والصور والأصول الأخرى المدمجة تحتفظ بتراخيصها المفتوحة الخاصة؛ كلٌّ منها مُدرج
مع مؤلفه ومصدره وترخيصه في [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
صور العرض أعمال ملكية عامة، مستخدمة عبر ويكيميديا كومنز: أنسل آدمز، *التيتونز ونهر الأفعى*
‏(‏1942، الأرشيف الوطني الأمريكي)؛ دوروثيا لانج، *الأم المهاجرة* ‏(‏1936، مكتبة الكونغرس)؛ بيل أندرس / ناسا،
*شروق الأرض* ‏(‏1968)؛ ناسا، *الرخامة الزرقاء* ‏(‏1972). مكتبة العرض مولّدة إجرائياً بـ LightCraft. خط الواجهة:
‏Inter ‏(‏SIL OFL 1.1). البناءات المصنوعة بـ [craft-fonts](https://github.com/storytold/craft-fonts) ‏(‏كل الإصدارات الرسمية)
تضمّن أيضاً خطوطه العربية والصينية واليابانية ‏(‏Noto Sans Arabic، Noto Sans CJK SC، BIZ UDPGothic، BIZ UDMincho، Shippori Mincho؛ SIL OFL 1.1)، المدرجة في
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) عنده. كل الأيقونات أصلية.
اسم ArtCraft وعلامته الكتابية وشعاراته في [`docs/brand/`](docs/brand/) علامات تجارية لفريق
ArtCraft ولا يشملها هذا الترخيص. يجوز استخدامها دون تعديل فقط، وفقط كجزء
من هذا المستودع وLightCraft، بموجب [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
على النسخ المشتقة والنسخ المعدلة إزالتها.
<sub>‏Adobe وPhotoshop وIllustrator وPremiere Pro وLightroom وAcrobat وAfter Effects وInDesign علامات تجارية أو علامات تجارية مسجلة لشركة Adobe Inc. في الولايات المتحدة و/أو دول أخرى. LightCraft مشروع مستقل مفتوح المصدر، لا يتبع Adobe Inc. ولا ترعاه ولا تصادق عليه؛ هذه الأسماء مستخدمة فقط لوصف مسارات العمل المتوافق معها.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>من صنع فريق <a href="https://getartcraft.com/">ArtCraft</a> والمجتمع.</sub>
</p>
## تاريخ النجوم
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
</div>
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">تعال اصنع الأشياء معنا</h3>
<div dir="rtl">
<p align="center">
Discord عندنا هو المكان الذي يتسكع فيه فنانو كل الأنواع: من يرسمون، ويصوّرون، وينقّطون،
يقطعون الفيلم، ويضبطون الحروف، ومن ما زالوا يكتشفون ما يحبون صنعه. شارك ما تعمل عليه،
اطلب المساعدة، وأخبرنا بما تعطّل، أو بما تتمنى أن تستطيع هذه الأدوات فعله.
مهما كانت وساطتك ومهما طال وقتك فيها، لك مكان هنا.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">تطبيقات Crafting Apps</a> ·
<a href="https://github.com/storytold">GitHub</a>
</p>
</div>
+2 -1
View File
@@ -14,7 +14,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.ar.md">العربية</a>
</p>
<p align="center">
+2 -1
View File
@@ -14,7 +14,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.ar.md">العربية</a>
</p>
<p align="center">
+10 -2
View File
@@ -31,7 +31,7 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
`main` does not yet pass the tests CI now runs on macOS, Windows and Linux (`ci.yml`). **Why alpha:** core workflows run end to end, but readiness is below
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
@@ -47,6 +47,8 @@ Hours are calibrated on this repo's PR history; see
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 82% of 1,446 Adobe models decode, 17% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
@@ -109,13 +111,17 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
5. AI masks: Subject / Sky / People (40–70 h + model decision)
6. Compressed ORF and HE NEF (28–55 h)
7. Lens profile database of our own (40–80 h)
8. Tests in CI on macOS, Windows and Linux (6–12 h)
8. Tests in CI on macOS, Windows and Linux: green on `main` (2–4 h; `ci.yml` runs, #1050)
9. Per-model raw verification (20–35 h)
## Progress log
- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full
size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged.
- 2026-10-11 (CI): `ci.yml` runs `cargo xtask ci` on Linux and clippy and the tests on macOS and Windows, and the CPU ↔ GPU equivalence tests on lavapipe, Metal and WARP (`LIGHTCRAFT_GPU_ALLOW_SOFTWARE`, tests only), within the documented bounds on all three (max 1 LSB on lavapipe and WARP, 2 in one case on the runner's Metal; the bound is 3). `main` fails there until the #989 fixes and #1052 (Windows) land (#1050). Headline numbers unchanged.
- 2026-10-12: Minolta / Konica Minolta MRW decodes from sensor data (12-bit packed and 16-bit words,
RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on
CC0 files. 241 models verified, 222 preview-only; headline numbers unchanged.
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
@@ -164,6 +170,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-11 | minor | Tests in CI on macOS, Windows and Linux (`ci.yml`, #1050), GPU equivalence tests on software adapters; not green on `main` yet, gap 9 6–12 h → 2–4 h; headline numbers unchanged |
| 2026-10-12 | minor | Minolta MRW decoded: 6 more verified models (241), 11 fewer preview-only; headline numbers unchanged |
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
+3
View File
@@ -25,6 +25,9 @@ lightcraft-merge = { workspace = true }
serde_json = { workspace = true }
dhat = { workspace = true, optional = true }
[dev-dependencies]
lightcraft-tiff = { workspace = true }
[features]
default = ["denoise"]
denoise = ["lightcraft-engine/denoise"]
+21 -10
View File
@@ -104,7 +104,8 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
(ARW, NEF/NRW, RAF, CR2/CR3): up to N files spread over the
folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -186,8 +187,9 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw =
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
let is_raw = |p: &Path| {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf", "cr2", "cr3"].iter().any(|x| e.eq_ignore_ascii_case(x)))
};
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
@@ -240,28 +242,37 @@ fn calibrate(args: &[String]) -> Result<(), String> {
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6);
let next = std::sync::atomic::AtomicUsize::new(0);
let done = std::sync::atomic::AtomicUsize::new(0);
let pools: Vec<lightcraft_engine::camera_profiles::Pool> = std::thread::scope(|scope| {
let mut pools: Vec<(usize, lightcraft_engine::camera_profiles::Pool)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let mut pools = Vec::new();
loop {
let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(path) = files.get(index) else { break };
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes));
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
match result {
Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()),
Ok(Some(model)) => {
eprintln!("[{n}/{}] {} ({model})", files.len(), path.display());
pools.push((index, pool));
}
Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()),
Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()),
}
}
pool
pools
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
handles.into_iter().filter_map(|h| h.join().ok()).flatten().collect()
});
// The fitting holdout and floating-point sums depend on pair order. Keep parallel decoding,
// but merge in the sorted input order rather than worker admission/completion order.
pools.sort_by_key(|(index, _)| *index);
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
for p in pools {
for (_, p) in pools {
pool.merge(p);
}
let mut written = 0;
+94
View File
@@ -72,6 +72,100 @@ fn calibrate_reads_raf_inputs_and_reports_bad_files() {
assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
}
#[test]
fn calibrate_discovers_canon_inputs_without_touching_files() {
let input = tmp("calibrate-canon-inputs");
let nested = input.join("nested");
std::fs::create_dir_all(&nested).unwrap();
let extensions = ["ARW", "nef", "NRW", "raf", "CR2", "cr3"];
for extension in extensions {
std::fs::write(nested.join(format!("camera.{extension}")), b"invalid raw fixture").unwrap();
}
// DNG supplies its own colour model; rendered inputs cannot train a vendor RAW profile.
for extension in ["dng", "jpg", "png", "tif", "txt", "rw2", "rwl", "raw", "pef", "srw", "orf"] {
std::fs::write(nested.join(format!("ignored.{extension}")), b"ignored fixture").unwrap();
}
let hidden = input.join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("hidden.CR3"), b"hidden fixture").unwrap();
let out = tmp("canon-calibration-profiles");
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
assert!(!result.status.success(), "invalid raws cannot produce a profile");
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(stderr.contains("6 of 6 raw files"), "Canon inputs silently ignored: {stderr}");
for extension in extensions {
let name = format!("camera.{extension}");
assert!(stderr.contains(&name), "{name} was not passed to the decoder: {stderr}");
assert_eq!(std::fs::read(nested.join(name)).unwrap(), b"invalid raw fixture");
}
assert!(stderr.contains("no profile written"), "{stderr}");
assert!(!out.exists(), "invalid inputs must not create a profile folder");
}
/// Original, uncompressed Bayer TIFF with Sony container metadata and an authored JPEG chart.
/// The varying dimensions let worker completion/admission order differ between runs.
fn calibration_arw(index: usize) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let (w, h) = (96 + index * 16, 64 + index * 8);
let colour = |x: usize, y: usize| {
let (x, y) = (x / 8, y / 8);
[0.06 + 0.6 * x as f32 / (w / 8) as f32, 0.07 + 0.55 * y as f32 / (h / 8) as f32, 0.08 + 0.5 * ((x * 7 + y * 3 + index) % 13) as f32 / 13.0]
};
let pixels: Vec<u8> = (0..h)
.flat_map(|y| {
(0..w).flat_map(move |x| {
let ch = [[0, 1], [1, 2]][y % 2][x % 2];
(512 + (colour(x, y)[ch] * 15871.0) as u16).to_le_bytes()
})
})
.collect();
let preview = lightcraft_raster::Rgba8::from_fn(w, h, |x, y| {
let p = colour(x, y).map(|v| (255.0 * (1.055 * v.powf(1.0 / 2.4) - 0.055)).round() as u8);
[p[0], p[1], p[2], 255]
});
let jpeg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&preview),
95,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.unwrap();
let mut raw = IfdBuilder::new();
raw.set(t::MAKE, Value::Ascii("SONY".into()));
raw.set(t::MODEL, Value::Ascii("TEST-CALIBRATION".into()));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::WHITE_LEVEL, Value::Long(vec![16383]));
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![pixels] });
// An undefined TIFF value is a supported camera-JPEG preview carrier.
raw.set(0xc7ff, Value::Undefined(jpeg));
TiffWriter::default().write(&[raw]).unwrap()
}
#[test]
fn calibrate_is_reproducible_with_parallel_workers() {
let input = tmp("calibration-repeatable-input");
std::fs::create_dir_all(&input).unwrap();
for i in 0..8 {
std::fs::write(input.join(format!("chart-{i}.ARW")), calibration_arw(i)).unwrap();
}
let mut first = None;
for run in 0..4 {
let out = tmp(&format!("calibration-repeatable-out-{run}"));
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
let bytes = std::fs::read(out.join("TEST-CALIBRATION.json")).unwrap();
match &first {
None => first = Some(bytes),
Some(expected) => assert!(&bytes == expected, "the same chart files produced a different camera profile"),
}
}
}
fn mean(path: &std::path::Path) -> f64 {
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
let img = d.to_srgb8();
@@ -0,0 +1,72 @@
//! `lightcraft-cli run` with a script that sends the settings `develop.get` returned back whole after
//! each change: the photo's Upright correction stays in its frame (issue #205; the second send used
//! to put it back in the frame the settings were read in).
use std::io::Write;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
const AUTO: &str = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000";
/// A 60 × 40 JPEG stored on its side (EXIF 6) with a Lightroom sidecar holding a saved Auto matrix.
fn fixture(dir: &std::path::Path) -> std::path::PathBuf {
let (w, h) = (60usize, 40usize);
let img = lightcraft_raster::Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i % w * 4) as u8, (i / w * 6) as u8, 57, 255]).collect() };
let exif = lightcraft_codecs::exif::minimal_exif(6);
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
let bytes =
lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, lightcraft_codecs::ChromaSubsampling::S444, &meta).unwrap();
std::fs::write(dir.join("a.jpg"), bytes).unwrap();
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PerspectiveUpright="1" crs:UprightVersion="151388160" crs:UprightTransformCount="6" crs:UprightTransform_1="{AUTO}"/></rdf:RDF></x:xmpmeta>"#
);
std::fs::write(dir.join("a.xmp"), xmp).unwrap();
dir.join("a.jpg")
}
/// `lightcraft-cli run` with `args` and the JSON-lines `script` on stdin: each command's result.
fn run(args: &[&str], script: &str) -> Vec<Value> {
let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
let o = child.wait_with_output().unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.inspect(|l| assert_eq!(l["ok"], true, "{l}"))
.map(|l| l["result"].clone())
.collect()
}
fn line(command: &str, params: Value) -> String {
format!("{}\n", json!({"command": command, "params": params}))
}
#[test]
fn a_script_sending_the_settings_back_whole_after_each_change_keeps_the_correction() {
let dir = std::env::temp_dir().join(format!("lightcraft-cli-upright-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let photo = fixture(&dir);
let lib = dir.join("library");
let args = ["--library", lib.to_str().unwrap(), "--import", photo.to_str().unwrap(), "--script", "-"];
// the original correction, then Rotate Right and the settings read once
let out = run(&args, &(line("develop.get", json!({})) + &line("photo.rotateRight", json!({})) + &line("develop.get", json!({}))));
let original = out[0]["geometry"]["upright_transform"].clone();
assert!(original.is_array(), "{original}");
let mut local = out[2].clone();
assert_ne!(local["geometry"]["upright_transform"], original);
// sent back whole: the rotation set back to Normal, then also Exposure
local["orientation"] = json!("Normal");
let mut script = line("develop.merge", json!({"settings": local}));
local["light"]["exposure"] = json!(0.5);
script += &(line("develop.merge", json!({"settings": local})) + &line("develop.get", json!({})));
let out = run(&args, &script);
let now = &out[2];
assert_eq!((&now["orientation"], &now["light"]["exposure"]), (&json!("Normal"), &json!(0.5)));
assert_eq!(now["geometry"]["upright_transform"], original);
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -270,6 +270,8 @@ mod tests {
assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None);
}
/// (Unix: `Path::is_absolute` decides by the platform's rules, and desktop entries run there)
#[cfg(unix)]
#[test]
fn desktop_entries_give_their_program() {
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
+2 -1
View File
@@ -76,7 +76,8 @@ impl eframe::App for App {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0);
self.0.ui(ui);
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
+4 -2
View File
@@ -26,8 +26,10 @@ const QUIT: &str = "app.quit";
const SETTINGS: &str = "app.settings";
const SETTINGS_KEY: &str = "Cmd+,";
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
/// or the active mask): left to the egui handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete"];
/// or the active mask): left to the egui handler, which knows the context. Shift+Tab (Hide All
/// Panels) stays there too: it has no native accelerator, and the egui handler cancels egui's own
/// Shift+Tab focus traversal when it fires.
const CONTEXTUAL: &[&str] = &["X", "Delete", "Shift+Tab"];
/// Shortcuts text fields need while they have focus.
const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"];
+8
View File
@@ -17,6 +17,11 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.alvarohn]
real_name = "Álvaro HN"
display_name = "Álvaro HN"
sync_github_name = false
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -27,3 +32,6 @@ sync_github_name = true
[people.sanyamsingh001]
display_name = "sanyamsingh001"
[people.mdlmarkham]
sync_github_name = true
+1
View File
@@ -12,6 +12,7 @@ workspace = true
lightcraft-develop = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-sysmem = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+1 -1
View File
@@ -455,7 +455,7 @@ impl Journal {
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
}
}
catalog.revision = 0;
catalog.set_revision(0);
Ok((j, catalog, report))
}
+11
View File
@@ -21,6 +21,7 @@ pub mod local;
pub mod lock;
pub mod model;
pub mod query;
pub mod revision;
pub mod rules;
pub mod safe_file;
pub mod stacks;
@@ -38,6 +39,7 @@ pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
pub use lock::{LibraryLock, LockError, LockOwner};
pub use model::*;
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
pub use revision::ViewRevision;
pub use rules::{Match, Rule, RuleSet};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
@@ -281,6 +283,11 @@ pub struct Catalog {
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
/// Increments on every applied op that changes more than photos' develop state
/// ([`Op::is_develop_only`]): what views that don't read develop state key on (see
/// [`revision`]), so a slider drag doesn't work them out again on every tick.
#[serde(skip)]
pub library_revision: u64,
}
impl Catalog {
@@ -485,8 +492,12 @@ impl Catalog {
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
let develop_only = op.is_develop_only();
let inv = self.apply_inner(op)?;
self.revision += 1;
if !develop_only {
self.library_revision += 1;
}
Ok(inv)
}
+2 -1
View File
@@ -318,7 +318,7 @@ pub struct Analysis {
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
@@ -577,6 +577,7 @@ mod edited_tests {
("a.cr2", "CR2", true),
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.mrw", "MRW", 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");
+275
View File
@@ -0,0 +1,275 @@
//! What changed, for caches: [`Catalog::revision`] counts every applied op, and
//! [`Catalog::library_revision`] every op except those that only change photos' develop state
//! ([`Op::is_develop_only`]: settings, edit time, History, snapshots). A slider drag applies a
//! develop op per tick; views that don't read develop state (most filters and sorts, the sidebar's
//! counts and trees) key on the library revision and aren't worked out again on every tick.
//!
//! Some views *do* read develop state: "edited" filters and rules, Edit Date, Cropped, Treatment
//! and the size rules (crop and orientation change a photo's shown size). Those key on the full
//! revision ([`Catalog::view_revision`] with `reads_develop`), through the smart albums a filter
//! or rule tests too.
use std::collections::HashSet;
use crate::{AlbumId, Catalog, Filter, Op, Rule, RuleSet, Sort, SortKey};
/// What a cached view of the catalog was worked out from: the library revision, and the full
/// revision as well when the view reads develop state. Equal values mean the view is unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct ViewRevision {
pub library: u64,
/// [`Catalog::revision`] for views that read develop state; `None` for the others.
pub all: Option<u64>,
}
/// Smart-album rule fields whose matches don't depend on a photo's develop state. Every other
/// field (including one added later and not listed here) counts as reading it, so a forgotten
/// field costs a query per slider tick, never a stale view.
const FIELDS_WITHOUT_DEVELOP: &[&str] = &[
"rating",
"flag",
"label",
"text",
// an album rule reads what that album holds: see `Catalog::filter_reads_develop`
"album",
"virtualCopy",
"copyName",
"stacked",
"fileName",
"extension",
"filePath",
"kind",
"format",
"duration",
"captureDate",
"importDate",
"keywords",
"keywordCount",
"person",
"personCount",
"title",
"caption",
"altText",
"creator",
"copyright",
"copyrightStatus",
"camera",
"lens",
"focalLength",
"aperture",
"shutterSpeed",
"iso",
"location",
"city",
"state",
"country",
"hasGps",
"sharpness",
"bestOfGroup",
];
/// Whether a rule on `field` can match differently after a develop edit (Has Edits, Edit Date,
/// Cropped, Treatment, and the sizes a crop or rotation changes).
pub fn field_reads_develop(field: &str) -> bool {
!FIELDS_WITHOUT_DEVELOP.contains(&field)
}
impl Op {
/// Whether the op only changes photos' develop state: their settings and edit time, History
/// and snapshots. Such ops bump [`Catalog::revision`] but not [`Catalog::library_revision`].
pub fn is_develop_only(&self) -> bool {
let mut next = vec![self];
while let Some(op) = next.pop() {
match op {
Op::SetDevelop { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetVersions { .. } => {}
Op::Batch { ops } => next.extend(ops),
_ => return false,
}
}
true
}
}
impl RuleSet {
/// Whether any of the rules (nested groups included) tests develop state itself; the albums
/// an Album rule tests are [`Catalog::filter_reads_develop`]'s concern.
pub fn reads_develop(&self) -> bool {
let mut next = vec![self];
while let Some(set) = next.pop() {
for rule in &set.rules {
match rule {
Rule::Group { group } => next.push(group),
Rule::Field { field, .. } if field_reads_develop(field) => return true,
Rule::Field { .. } => {}
}
}
}
false
}
}
impl Filter {
/// Whether the filter itself tests develop state: Edited, an `edited:` search token or a rule
/// that does. The albums it tests are [`Catalog::filter_reads_develop`]'s concern.
pub fn reads_develop(&self) -> bool {
self.edited.is_some()
|| self.text.split_whitespace().any(|t| t.to_lowercase().split_once(':').is_some_and(|(field, _)| field == "edited"))
|| self.rule_set.as_ref().is_some_and(RuleSet::reads_develop)
}
}
impl Sort {
/// Whether the order depends on develop state (Edit Date: every edit moves the photo).
pub fn reads_develop(&self) -> bool {
self.key == SortKey::EditDate
}
}
impl Catalog {
/// Whether which photos `filter` lets through can change with a develop edit: the filter's
/// own fields and rules, and those of every smart album it tests, directly or through other
/// smart albums (loops in saved rules end the search).
pub fn filter_reads_develop(&self, filter: &Filter) -> bool {
if filter.reads_develop() {
return true;
}
self.albums_read_develop(filter.albums_tested())
}
/// Whether what album `id` holds can change with a develop edit (a smart album whose rules,
/// or those of the smart albums they test, read develop state). Plain albums never do.
pub fn album_reads_develop(&self, id: AlbumId) -> bool {
self.albums_read_develop(vec![id])
}
/// Whether any smart album's contents can change with a develop edit (the sidebar's counts).
pub fn smart_albums_read_develop(&self) -> bool {
self.albums_read_develop(self.albums().filter(|a| a.is_smart()).map(|a| a.id).collect())
}
fn albums_read_develop(&self, mut next: Vec<AlbumId>) -> bool {
let mut seen: HashSet<AlbumId> = HashSet::new();
while let Some(id) = next.pop() {
if !seen.insert(id) {
continue;
}
let Some(rules) = self.album(id).and_then(|a| a.smart.as_deref()) else { continue };
if rules.reads_develop() {
return true;
}
next.extend(rules.albums_tested());
}
false
}
/// The revision a cached view keys on: the library revision, plus the full revision when the
/// view reads develop state (see the module docs).
pub fn view_revision(&self, reads_develop: bool) -> ViewRevision {
ViewRevision { library: self.library_revision, all: reads_develop.then_some(self.revision) }
}
/// Count revisions from `at` (both of them), as a library just loaded does.
pub fn set_revision(&mut self, at: u64) {
self.revision = at;
self.library_revision = at;
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use super::*;
use crate::{Album, Photo, PhotoId, Source};
fn photo(id: u64) -> Photo {
Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("IMG_{id}.jpg"), "JPEG", 6000, 4000, "2026-01-01T00:00:00")
}
fn rules(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
fn smart(id: u64, rules: RuleSet) -> Op {
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
Op::AddAlbum { album: a }
}
#[test]
fn develop_ops_bump_only_the_full_revision() {
let mut c = Catalog::new();
c.apply(Op::AddPhoto { photo: Box::new(photo(1)) }).unwrap();
let (rev, lib) = (c.revision, c.library_revision);
let mut s = (*c.photo(PhotoId(1)).unwrap().develop).clone();
s.light.exposure = 1.0;
let set =
Op::SetDevelop { id: PhotoId(1), settings: Arc::new(s.clone()), label: "Exposure".into(), edited: Some("2026-02-01T00:00:00".into()) };
let step = Op::PushHistory { id: PhotoId(1), step: crate::HistoryStep { label: "Exposure".into(), settings: Arc::new(s) } };
c.apply(Op::Batch { ops: vec![set, step] }).unwrap();
assert_eq!((c.revision, c.library_revision), (rev + 1, lib), "a develop edit leaves the library revision");
c.apply(Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
assert_eq!((c.revision, c.library_revision), (rev + 2, lib + 1));
// a batch with any library op is a library change
let mixed =
Op::Batch { ops: vec![Op::SetHistory { id: PhotoId(1), history: Vec::new() }, Op::SetFlag { id: PhotoId(1), flag: crate::Flag::Pick }] };
c.apply(mixed).unwrap();
assert_eq!(c.library_revision, lib + 2);
// a failed op changes neither
assert!(c.apply(Op::SetRating { id: PhotoId(9), rating: 3 }).is_err());
assert_eq!((c.revision, c.library_revision), (rev + 3, lib + 2));
}
#[test]
fn filters_sorts_and_rules_that_read_develop_state() {
assert!(!Filter::default().reads_develop());
assert!(!Filter { text: "harbour rating:3".into(), rating: 2, keyword: Some("travel".into()), ..Default::default() }.reads_develop());
assert!(Filter { edited: Some(false), ..Default::default() }.reads_develop());
assert!(Filter { text: "harbour Edited:yes".into(), ..Default::default() }.reads_develop());
assert!(Sort { key: SortKey::EditDate, ..Default::default() }.reads_develop());
assert!(!Sort { key: SortKey::FileName, ..Default::default() }.reads_develop());
for field in ["edited", "cropped", "treatment", "editDate", "longEdge", "shortEdge", "aspect", "megapixels"] {
let nested = rules(
json!({"rules": [{"field": "rating", "op": ">=", "value": 1}, {"group": {"match": "any", "rules": [{"field": field, "op": "is", "value": true}]}}]}),
);
assert!(nested.reads_develop(), "{field}");
}
assert!(
!rules(json!({"rules": [{"field": "rating", "op": ">=", "value": 1}, {"field": "keywords", "op": "contains", "value": "x"}]}))
.reads_develop()
);
}
/// Every rule field is either known not to read develop state or counts as reading it: a
/// new field must be put on one side on purpose.
#[test]
fn every_rule_field_is_classified() {
let reads: Vec<&str> = crate::rules::FIELDS.iter().map(|f| f.0).filter(|f| field_reads_develop(f)).collect();
assert_eq!(reads, ["editDate", "longEdge", "shortEdge", "aspect", "megapixels", "edited", "cropped", "treatment"]);
for f in FIELDS_WITHOUT_DEVELOP {
assert!(crate::rules::FIELDS.iter().any(|x| x.0 == *f), "{f} is not a rule field");
}
}
#[test]
fn smart_albums_read_develop_through_the_albums_they_test() {
let mut c = Catalog::new();
c.apply(smart(1, rules(json!({"rules": [{"field": "rating", "op": ">=", "value": 3}]})))).unwrap();
c.apply(smart(2, rules(json!({"rules": [{"field": "edited", "op": "is", "value": true}]})))).unwrap();
// 3 tests 2 (which reads develop); 4 and 5 test each other (a loop) and 1
c.apply(smart(3, rules(json!({"rules": [{"field": "album", "op": "is", "value": 2}]})))).unwrap();
c.apply(smart(4, rules(json!({"rules": [{"field": "album", "op": "is", "value": 5}, {"field": "album", "op": "is", "value": 1}]})))).unwrap();
c.apply(smart(5, rules(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})))).unwrap();
c.apply(Op::AddAlbum { album: Album::new(AlbumId(6), "Plain") }).unwrap();
let reads = |id: u64| c.album_reads_develop(AlbumId(id));
assert_eq!([1, 2, 3, 4, 5, 6, 7].map(reads), [false, true, true, false, false, false, false]);
assert!(c.filter_reads_develop(&Filter { album: Some(AlbumId(3)), ..Default::default() }));
assert!(!c.filter_reads_develop(&Filter { album: Some(AlbumId(4)), ..Default::default() }));
assert!(c.smart_albums_read_develop());
c.apply(Op::RemoveAlbum { id: AlbumId(2) }).unwrap();
c.apply(Op::RemoveAlbum { id: AlbumId(3) }).unwrap();
assert!(!c.smart_albums_read_develop());
}
}
+79 -5
View File
@@ -88,10 +88,14 @@ impl Store for FsStore {
self.appender = None;
}
let tmp = self.path(&format!("{name}.tmp"));
{
let written = (|| {
let mut f = std::fs::File::create(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
sync_file(&f, false)
})();
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, self.path(name))?;
self.sync_dir();
@@ -109,7 +113,7 @@ impl Store for FsStore {
fill(&mut w)?;
w.flush()?;
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
f.sync_all()?;
sync_file(&f, false)?;
Ok(w.n)
})();
let n = match written {
@@ -135,7 +139,7 @@ impl Store for FsStore {
self.appender = Some((name.to_string(), f));
}
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
let written = f.write_all(data).and_then(|()| f.sync_data());
let written = f.write_all(data).and_then(|()| sync_file(f, true));
if written.is_err() {
// The handle may be dead (a network share that reconnected, a removed volume): the next
// attempt reopens the file. Part of `data` may be in the file; the caller cuts it back
@@ -156,7 +160,7 @@ impl Store for FsStore {
Err(e) => return Err(e),
};
f.set_len(len)?;
f.sync_all()
sync_file(&f, false)
}
fn describe(&self) -> String {
@@ -169,6 +173,76 @@ impl Store for FsStore {
}
}
/// Flush a file to disk: `sync_all`, or `sync_data` when `data_only`.
///
/// On macOS both are `F_FULLFSYNC`, which an SMB share refuses with ENOTSUP (issue #1407) while a
/// plain `fsync` works there: that is the fallback. If it fails too, the write fails.
fn sync_file(f: &std::fs::File, data_only: bool) -> io::Result<()> {
#[cfg(test)]
let r = if NO_FULL_SYNC.with(std::cell::Cell::get) {
Err(enotsup())
} else if data_only {
f.sync_data()
} else {
f.sync_all()
};
#[cfg(not(test))]
let r = if data_only { f.sync_data() } else { f.sync_all() };
match r {
Err(e) if flush_unsupported(&e) => plain_fsync(f),
r => r,
}
}
fn plain_fsync(f: &std::fs::File) -> io::Result<()> {
#[cfg(test)]
PLAIN_FSYNCS.with(|c| c.set(c.get() + 1));
lightcraft_sysmem::fsync(f)
}
/// The volume can't flush a file to disk (ENOTSUP on macOS, `Unsupported` elsewhere).
fn flush_unsupported(e: &io::Error) -> bool {
const ENOTSUP: i32 = 45;
e.kind() == io::ErrorKind::Unsupported || (cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP))
}
#[cfg(test)]
thread_local! {
static NO_FULL_SYNC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static PLAIN_FSYNCS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Tests: how many plain `fsync` fallbacks [`FsStore`] made on the calling thread.
#[cfg(test)]
pub(crate) fn plain_fsyncs() -> u64 {
PLAIN_FSYNCS.with(std::cell::Cell::get)
}
/// What a volume without a full flush answers (macOS: ENOTSUP, os error 45, from `F_FULLFSYNC`).
#[cfg(test)]
fn enotsup() -> io::Error {
if cfg!(target_vendor = "apple") { io::Error::from_raw_os_error(45) } else { io::Error::from(io::ErrorKind::Unsupported) }
}
/// Tests: while the guard lives, every file sync [`FsStore`] makes on the calling thread fails
/// as on an SMB share mounted by macOS (issue #1407).
#[cfg(test)]
pub(crate) fn full_sync_unsupported() -> NoFullSync {
NO_FULL_SYNC.with(|c| c.set(true));
NoFullSync(())
}
/// Ends a [`full_sync_unsupported`] when dropped.
#[cfg(test)]
pub(crate) struct NoFullSync(());
#[cfg(test)]
impl Drop for NoFullSync {
fn drop(&mut self) {
NO_FULL_SYNC.with(|c| c.set(false));
}
}
/// Counts the bytes written through it.
struct Counter<W> {
inner: W,
+33
View File
@@ -286,6 +286,39 @@ fn fs_store_roundtrip() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A library on an SMB share (issue #1407): macOS can't make the full flush there (`F_FULLFSYNC`
/// fails with ENOTSUP), so the files are flushed with a plain `fsync` and the new library's
/// snapshot, its log and `prefs.json` are still written.
#[test]
fn a_volume_without_a_full_flush_still_gets_its_files() {
let dir = std::env::temp_dir().join(format!("lc-journal-nofullsync-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fsyncs = crate::store::plain_fsyncs();
let c = {
let _smb = crate::store::full_sync_unsupported();
let (mut j, mut c, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert!(r.created);
assert!(dir.join(SNAPSHOT).exists(), "the new library's snapshot is written");
let id = c.alloc_photo_id();
let add = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: "/a.jpg".into() }, "a.jpg", "JPEG", 1, 1, "t")) };
c.apply(add.clone()).unwrap();
j.append(&[add]).unwrap();
j.snapshot(&c).unwrap();
let mut s = FsStore::open(&dir).unwrap();
s.write_atomic("prefs.json", b"{}").unwrap();
s.truncate(LOG, 0).unwrap();
// the new library's snapshot, the append, the snapshot and the emptied log, prefs.json, the truncate
assert_eq!(crate::store::plain_fsyncs() - fsyncs, 6, "each sync falls back to fsync");
c
};
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
names.sort();
assert_eq!(names, vec![LOG, SNAPSHOT, "prefs.json"], "no temp file left behind");
let (_, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert_eq!((r.snapshot_seq, c2.to_snapshot()), (1, c.to_snapshot()));
let _ = std::fs::remove_dir_all(&dir);
}
/// The streamed snapshot is byte-for-byte the format written before streaming (issue #37), so
/// existing libraries load and older builds can read new snapshots; FsStore and MemStore agree.
#[test]
+6 -2
View File
@@ -84,17 +84,21 @@ pub enum Process {
/// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base
/// is taken of a fine median of log luminance (issue #632).
V2,
/// Dehaze estimates haze from a patch-minimum dark channel, eases in, and never pushes a
/// pixel below a tenth of its darkest channel (issue #1237).
V3,
}
impl Process {
/// Every process, oldest first.
pub const ALL: [Process; 2] = [Process::V1, Process::V2];
pub const LATEST: Process = Process::V2;
pub const ALL: [Process; 3] = [Process::V1, Process::V2, Process::V3];
pub const LATEST: Process = Process::V3;
pub const fn version(self) -> ProcessVersion {
match self {
Process::V1 => ProcessVersion(1),
Process::V2 => ProcessVersion(2),
Process::V3 => ProcessVersion(3),
}
}
}
+57
View File
@@ -70,6 +70,37 @@ fn typical() -> DevelopSettings {
s
}
/// [`typical`] plus three masks: a radial gradient, a brush (three strokes) and either a linear
/// gradient with Refine Edges (`refine`; such edits render on the CPU) or a Subject mask (the
/// heuristic, which the GPU renderer evaluates on the CPU).
fn with_masks(refine: bool) -> DevelopSettings {
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let mut s = typical();
let mask = |id: u32, shape: MaskShape, exposure: f64, refine: f64| Mask {
id,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { exposure, clarity: 10.0, ..Default::default() },
refine,
..Default::default()
};
let radial = MaskShape::Radial { center: Point::new(0.45, 0.5), rx: 0.3, ry: 0.2, angle: 15.0, feather: 60.0, invert: false };
let strokes = (0..3)
.map(|i| BrushStroke {
points: (0..40).map(|k| Point::new(0.1 + k as f64 * 0.02, 0.2 + i as f64 * 0.25 + (k as f64 * 0.3).sin() * 0.05)).collect(),
size: 0.04,
..Default::default()
})
.collect();
let third = if refine {
mask(3, MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, -0.5, 60.0)
} else {
mask(3, MaskShape::Subject, 0.3, 0.0)
};
s.masks = vec![mask(1, radial, 0.5, 0.0), mask(2, MaskShape::Brush { strokes }, -0.3, 0.0), third];
s
}
/// Max and mean |Δ| (8-bit, RGB) between two renders.
fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) {
let (mut max, mut sum) = (0u8, 0u64);
@@ -214,6 +245,32 @@ fn main() {
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
if run("masks") {
// three masks (one with Refine Edges, or one Subject mask): a global exposure drag and a
// mask's own exposure drag, both of which leave the masks' alphas as they were
for refine in [true, false] {
let m = with_masks(refine);
let what = if refine { "masks+refine" } else { "masks+subject" };
let caches = [StageCache::default(), StageCache::default()];
for (req, size) in [(&view, "loupe 1920×1280"), (&draft, "draft 1152×768")] {
for g in backends {
drop(develop(&preview, &info, &m, req, Some(&caches[*g as usize]), *g));
}
row(&format!("{size} exposure drag, {what}:"), &mut |g| {
let mut t = m.clone();
k += 1.0;
t.light.exposure = 0.3 + (k % 50.0) * 0.01;
drop(develop(&preview, &info, &t, req, Some(&caches[g as usize]), g));
});
row(&format!("{size} mask exposure drag, {what}:"), &mut |g| {
let mut t = m.clone();
k += 1.0;
t.masks[0].adjust.exposure = 0.5 + (k % 50.0) * 0.01;
drop(develop(&preview, &info, &t, req, Some(&caches[g as usize]), g));
});
}
}
}
if run("finish") {
// per-pixel stage cost by feature (warm cache: exposure changes only)
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
+1 -1
View File
@@ -73,7 +73,7 @@ impl Unit {
pub struct TaskInfo {
pub id: u64,
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `renderDng`.
pub kind: &'static str,
/// English; the frontend translates it.
pub label: String,
+2
View File
@@ -63,6 +63,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
| RawFormat::Mrw
)
}
@@ -2190,6 +2191,7 @@ mod tests {
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
RawFormat::Mrw,
];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
+4 -3
View File
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
//! A supported raw file without colour matrices gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -190,8 +190,9 @@ pub struct Pool {
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW,
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not a vendor
/// format handled by `camera_preview::file_local_look`, has its own colour matrices, no usable
/// camera JPEG, or too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// colour only: geometric lens corrections would stop the sensor proxy from binning
+94 -8
View File
@@ -158,7 +158,7 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut copies = Vec::new();
for id in ids {
copies.push(edit_copy(s, p, id, &opts)?);
copies.push(edit_copy(s, p, id, &opts, C)?);
}
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
s.selection = crate::Selection { active: new.first().copied(), ids: new };
@@ -171,14 +171,13 @@ const MAX_EDIT_COPIES: usize = 50;
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
const C: &str = "photo.editExternal";
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions, cmd: &'static str) -> Result<Value> {
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(cmd, format!("no photo {}", id.0)))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
None => return Err(bad(cmd, "a generated demo photo has no folder: give `dir`")),
},
};
let mut written = Vec::new();
@@ -189,11 +188,11 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
Ok(())
};
let to = crate::export::Destination { dir, exact: None };
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(cmd, e))?;
let ext = format!(".{}", opts.format.extension());
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(cmd, "nothing was written"))?;
let r = s.execute("library.import", &json!({"paths": [out]}))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(cmd, "the edit copy could not be added"))?;
// stack: the edit on top of the original, expanded so both show
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
Ok(json!({"path": out, "id": new, "original": id.0}))
@@ -361,6 +360,22 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Render to DNG (Lightroom Classic 15.5): see [`crate::render_dng`]. Here all in one go; more
/// than one photo shows in the activity stack and can be cancelled between photos (the copies made
/// until then stay). The desktop app runs the renders on a worker instead.
fn render_to_dng(s: &mut Session, p: &Value) -> Result<Value> {
let job = crate::render_dng::prepare(s, p)?;
let n = job.len();
let task = (n > 1).then(|| s.activity.start("renderDng", "Rendering to DNG", crate::activity::Cancel::Yes));
let (done, cancelled) = job.run(&mut |path, bytes| crate::export::write_file_durable(path, bytes), &mut |i| {
let Some(t) = &task else { return true };
t.progress(i as u64, n as u64);
!t.is_cancelled()
});
drop(task);
crate::render_dng::finish(s, done, cancelled)
}
pub fn edit_specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -390,6 +405,15 @@ pub fn edit_specs() -> Vec<CommandSpec> {
has_active,
edit_external
),
cmd!(
"photo.renderToDng",
"Render to DNG",
["Photo"],
None,
"{ids?, colorSpace?: proPhoto (default) | rec2020 | adobeRgb | displayP3 | srgb, dir?} — render each selected photo (or `ids`, at most 500) with its edits as a linear 16-bit float DNG `<name>-Rendered.dng` next to it (a demo photo into `dir`), add each stacked on its original and select them; a batch shows in the activity stack and can be cancelled between photos → {copies: [{path, id, original}], failed: [{id, error}], cancelled}",
has_selection,
render_to_dng
),
]
}
@@ -519,4 +543,66 @@ mod tests {
let undone = s.catalog.photo(id).unwrap();
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
}
fn scratch(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-render-dng-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn render_to_dng_adds_stacked_linear_dngs() {
let dir = scratch("stack");
let mut s = Session::with_demo();
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
let r = s.execute("photo.renderToDng", &json!({"ids": [ids[0].0, ids[1].0], "dir": dir.to_string_lossy()})).unwrap();
assert_eq!(r["cancelled"], false);
let copies = r["copies"].as_array().unwrap();
assert_eq!(copies.len(), 2, "{r}");
for (c, orig) in copies.iter().zip(&ids) {
let path = c["path"].as_str().unwrap();
assert!(path.ends_with("-Rendered.dng"), "{path}");
let raw = lightcraft_raw::decode(&std::fs::read(path).unwrap()).unwrap();
assert_eq!((raw.cpp, raw.cfa.is_none()), (3, true));
let new = PhotoId(c["id"].as_u64().unwrap());
assert_eq!(c["original"].as_u64(), Some(orig.0));
let st = s.catalog.stack_of(new).unwrap();
assert_eq!((st.photos.first(), st.photos.contains(orig)), (Some(&new), true), "stacked on top of its original");
}
assert_eq!(s.selection.ids.len(), 2, "the new DNGs are selected");
assert!(s.activity.list().is_empty(), "the activity row ends with the command");
// a second run never overwrites
let again = s.execute("photo.renderToDng", &json!({"ids": [ids[0].0], "dir": dir.to_string_lossy()})).unwrap();
assert_ne!(again["copies"][0]["path"], copies[0]["path"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_to_dng_rejects_bad_params_and_cancels_between_photos() {
let mut s = Session::with_demo();
let id = s.visible()[0];
assert!(s.execute("photo.renderToDng", &json!({"ids": [id.0]})).is_err(), "a demo photo needs `dir`");
assert!(s.execute("photo.renderToDng", &json!({"ids": [id.0], "dir": "/x", "colorSpace": "cmyk"})).is_err());
let dir = scratch("cancel");
let ids: Vec<u64> = s.visible().iter().take(6).map(|i| i.0).collect();
let activity = s.activity.clone();
let stop = std::thread::spawn(move || {
let t0 = std::time::Instant::now();
while t0.elapsed().as_secs() < 30 {
if let Some(t) = activity.list().into_iter().find(|t| t.kind == "renderDng" && t.cancellable) {
activity.cancel(t.id).unwrap();
return true;
}
std::thread::yield_now();
}
false
});
let r = s.execute("photo.renderToDng", &json!({"ids": ids, "dir": dir.to_string_lossy()})).unwrap();
assert!(stop.join().unwrap(), "the batch showed a cancellable activity row");
assert_eq!(r["cancelled"], true, "{r}");
assert!(r["copies"].as_array().unwrap().len() < 6);
assert!(s.activity.list().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}
+50 -18
View File
@@ -242,14 +242,27 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
cmd!("develop.merge", "Apply Settings JSON", [], None, "{settings: partial DevelopSettings JSON, label?}", has_active, |s, p| {
let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?;
let label = str_param(p, "label").unwrap_or("Edit").to_string();
edit(s, "develop.merge", &label, |d| {
*d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?;
Ok(())
})
}),
cmd!(
"develop.merge",
"Apply Settings JSON",
[],
None,
"{settings: partial DevelopSettings JSON, label?} (a change of `orientation` turns the photo's Upright correction with it, also when `geometry.upright_transform` states the photo's own as it was read: before a turn or with its numbers rounded; a different one is taken as given; a change of `geometry.upright` drops it to be solved again)",
has_active,
|s, p| {
let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?;
let label = str_param(p, "label").unwrap_or("Edit").to_string();
edit(s, "develop.merge", &label, |d| {
let before = d.clone();
*d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?;
// a turn turns the Upright correction the photo keeps, as Rotate does; a new mode
// drops it; a correction stated in the JSON is taken as given, unless it is the
// photo's own as it was read (settings sent back whole, in any frame)
crate::upright_transfer::settle_json(&before, d, &partial);
Ok(())
})
}
),
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
let ids = s.targets(p);
let ops = ids
@@ -788,6 +801,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
let d = s.develop_of(id).unwrap_or_default();
s.clipboard = Some(lightcraft_develop::extract_groups(&d, &s.copy_groups));
s.clipboard_source = s.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, &d, &s.library_identity));
Ok(json!({"groups": s.copy_groups}))
}
),
@@ -805,10 +819,13 @@ pub fn specs() -> Vec<CommandSpec> {
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
let source = s.catalog.photo(prev).map(|ph| crate::upright_transfer::source_of(ph, &ph.develop, &s.library_identity));
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &partial, 1.0), "Paste from Previous"))
.filter_map(|id| {
s.catalog.photo(*id).map(|ph| (*id, crate::upright_transfer::transferred(ph, &ph.develop, &partial, source.as_ref())))
})
.filter_map(|(id, d)| s.develop_op(id, d, "Paste from Previous"))
.collect::<Vec<_>>();
let n = ops.len();
s.commit("Paste from Previous", Op::Batch { ops })?;
@@ -831,10 +848,14 @@ pub fn specs() -> Vec<CommandSpec> {
clip = lightcraft_develop::extract_groups(&full, &only);
}
let ids = s.targets(p);
// copied in another library: its photo ids say nothing about this one's
let source = s.clipboard_source.clone().filter(|from| from.taken_in(&s.library_identity));
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &clip, 1.0), "Paste Settings"))
.filter_map(|id| {
s.catalog.photo(*id).map(|ph| (*id, crate::upright_transfer::transferred(ph, &ph.develop, &clip, source.as_ref())))
})
.filter_map(|(id, d)| s.develop_op(id, d, "Paste Settings"))
.collect::<Vec<_>>();
let n = ops.len();
s.commit("Paste Settings", Op::Batch { ops })?;
@@ -853,14 +874,17 @@ pub fn specs() -> Vec<CommandSpec> {
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let partial = lightcraft_develop::extract_groups(&d, &groups);
let source = s.catalog.photo(id).map(|ph| crate::upright_transfer::source_of(ph, &d, &s.library_identity));
let ops = s
.selection
.ids
.clone()
.into_iter()
.filter(|x| *x != id)
.filter_map(|x| s.develop_of(x).map(|d| (x, d)))
.filter_map(|(x, dd)| s.develop_op(x, lightcraft_develop::apply_partial(&dd, &partial, 1.0), "Sync Settings"))
.filter_map(|x| {
s.catalog.photo(x).map(|ph| (x, crate::upright_transfer::transferred(ph, &ph.develop, &partial, source.as_ref())))
})
.filter_map(|(x, dd)| s.develop_op(x, dd, "Sync Settings"))
.collect::<Vec<_>>();
let n = ops.len();
s.commit("Sync Settings", Op::Batch { ops })?;
@@ -973,7 +997,12 @@ pub fn specs() -> Vec<CommandSpec> {
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| s.develop_op(id, preset.apply(&d, amount), &format!("Preset: {}", preset.name)))
.filter_map(|(id, d)| {
// a preset carries no Upright correction: the photo keeps its own, turned with
// it, or solves its own for another mode
let after = s.preset_look(id, &preset, amount).unwrap_or_else(|| preset.apply(&d, amount));
s.develop_op(id, after, &format!("Preset: {}", preset.name))
})
.collect::<Vec<_>>();
s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?;
ok()
@@ -985,7 +1014,10 @@ pub fn specs() -> Vec<CommandSpec> {
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
let mut preset = Preset::from_settings(&pid, name, group, &d, &groups);
// an Upright correction is this photo's own analysis: a preset keeps the mode
preset.settings = crate::upright_transfer::without_correction(preset.settings);
s.presets.push(preset);
Ok(json!({"id": pid}))
}),
cmd!("preset.delete", "Delete Preset", [], None, "{id}", always, |s, p| {
@@ -1028,14 +1060,14 @@ pub fn specs() -> Vec<CommandSpec> {
let current = s.develop_of(id).unwrap_or_default().to_json();
let groups = groups_param(p);
let pr = user_preset(s, p, c)?;
pr.settings = match groups {
pr.settings = crate::upright_transfer::without_correction(match groups {
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
// the same keys as before, with the current values
None => {
let keys: Vec<String> = pr.settings.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
Value::Object(keys.into_iter().filter_map(|k| current.get(&k).cloned().map(|v| (k, v))).collect())
}
};
});
ok()
}
),
+184 -19
View File
@@ -24,15 +24,157 @@ fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
/// 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> {
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
Ok(rd
.flatten()
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
.filter_map(|e| {
let m = std::fs::metadata(e.path()).ok()?;
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
})
.collect())
AutoImportWalk::new(folder, false, Vec::new()).list().map(|l| l.files)
}
/// Folder levels an auto import with subfolders lists (the watched folder's own entries are
/// level 1).
pub const AUTO_IMPORT_MAX_DEPTH: usize = 16;
/// Directory entries an auto import with subfolders looks at per scan, at most.
pub const AUTO_IMPORT_MAX_ENTRIES: usize = 200_000;
/// How the watched folder is listed (`library.autoImportScan`): built from the session on the UI
/// thread ([`AutoImportWalk::of`]), listed on a worker thread ([`AutoImportWalk::list`]).
#[derive(Clone, Debug)]
pub struct AutoImportWalk {
pub folder: String,
/// Also list subfolders (photos only, by extension), up to [`AUTO_IMPORT_MAX_DEPTH`] levels and
/// [`AUTO_IMPORT_MAX_ENTRIES`] entries. Hidden folders and folder links are never entered.
pub subfolders: bool,
/// Folders never entered: the library's own (where auto import's copies go).
pub skip: Vec<std::path::PathBuf>,
/// Bounds of a listing with subfolders.
pub max_depth: usize,
pub max_entries: usize,
}
/// What [`AutoImportWalk::list`] found.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AutoImportListing {
/// (path, size) of each file.
pub files: Vec<(String, u64)>,
/// What was left out and why (an unreadable subfolder, a bound reached).
pub warnings: Vec<String>,
}
impl AutoImportWalk {
/// The session's auto-import listing, if a watched folder is set.
pub fn of(s: &Session) -> Option<Self> {
let d = &s.import_defaults;
let folder = d.auto_folder.clone()?;
// auto import copies into the library's Originals, so skipping the library also skips
// the destination when it sits inside the watched folder
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| vec![l.dir.clone()]).unwrap_or_default();
Some(AutoImportWalk::new(&folder, d.auto_subfolders, skip))
}
/// A listing of `folder` with the default bounds.
pub fn new(folder: &str, subfolders: bool, skip: Vec<std::path::PathBuf>) -> Self {
AutoImportWalk { folder: folder.to_string(), subfolders, skip, max_depth: AUTO_IMPORT_MAX_DEPTH, max_entries: AUTO_IMPORT_MAX_ENTRIES }
}
/// List the watched folder (an error only when the folder itself can't be read).
pub fn list(&self) -> std::result::Result<AutoImportListing, String> {
use std::path::{Path, PathBuf};
let folder = &self.folder;
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
let mut out = AutoImportListing::default();
if !self.subfolders {
out.files = rd
.flatten()
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
.filter_map(|e| {
let m = std::fs::metadata(e.path()).ok()?;
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
})
.collect();
return Ok(out);
}
// skipped folders compared by their canonical paths: a folder's canonical path is the
// canonical root joined with its path below the root (folder links aren't followed)
let canon = |p: &Path| std::fs::canonicalize(p).ok();
let skip: Vec<PathBuf> = self.skip.iter().filter_map(|p| canon(p)).collect();
let root = canon(Path::new(folder)).unwrap_or_else(|| PathBuf::from(folder));
if skip.contains(&root) {
return Ok(out);
}
let mut entries = 0usize;
// stopped at the entry bound / left folders below the depth bound unlisted
let (mut full, mut deep) = (false, false);
let mut bad_names = 0usize;
// (folder, its canonical path, its level: the watched folder's entries are level 1)
let mut stack: Vec<(PathBuf, PathBuf, usize, Option<std::fs::ReadDir>)> = vec![(PathBuf::from(folder), root, 1, Some(rd))];
while let Some((dir, canon_dir, level, rd)) = stack.pop() {
let rd = match rd {
Some(rd) => rd,
None => match std::fs::read_dir(&dir) {
Ok(rd) => rd,
// gone since it was listed (moved or deleted while scanning): nothing to say
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
out.warnings.push(format!("Auto Import skipped {}: {e}", dir.display()));
continue;
}
},
};
let mut below = Vec::new();
for e in rd.flatten() {
if entries >= self.max_entries {
full = true;
break;
}
entries = entries.saturating_add(1);
let name = e.file_name();
let Some(name) = name.to_str() else {
bad_names = bad_names.saturating_add(1);
continue;
};
if name.starts_with('.') {
continue;
}
let Ok(ft) = e.file_type() else { continue };
let path = e.path();
if ft.is_dir() {
let c = canon_dir.join(name);
if skip.contains(&c) {
continue;
}
if level >= self.max_depth {
deep = true;
continue;
}
below.push((path, c, level.saturating_add(1), None));
continue;
}
// a link to a file is a file; a link to a folder isn't followed (no loops)
if level > 1 && !crate::import::is_supported(&path) {
continue;
}
let Ok(m) = std::fs::metadata(&path) else { continue };
if m.is_file() {
out.files.push((path.to_string_lossy().to_string(), m.len()));
}
}
if full {
break;
}
below.sort_by(|a, b| b.0.cmp(&a.0));
stack.extend(below);
}
if bad_names > 0 {
out.warnings.push(format!("Auto Import skipped {bad_names} file or folder names that aren't valid Unicode in {folder}"));
}
if full {
out.warnings.push(format!(
"Auto Import stopped looking in {folder} after {} files and folders: photos beyond them aren't imported",
self.max_entries
));
}
if deep {
out.warnings.push(format!("Auto Import looks {} folder levels deep in {folder}: photos further down aren't imported", self.max_depth));
}
Ok(out)
}
}
/// `library.import`'s params, checked (no file-system calls).
@@ -232,6 +374,9 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
if let Some(d) = s.develop_of(id) {
let mut d = (*d).clone();
d.orientation = d.orientation.rotated(cw);
// a stored Upright correction (LightCraft's or Lightroom's) turns with the photo
let turn = if cw { lightcraft_geom::Orientation::Rotate90 } else { lightcraft_geom::Orientation::Rotate270 };
d.geometry.upright_transform = d.geometry.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn));
ops.extend(s.develop_op(id, d, "Rotate"));
}
}
@@ -1245,7 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Auto Import Settings",
[],
None,
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null, subfolders?: bool (also its subfolders: photos only, hidden folders, folder links and the library's own folder skipped, 16 levels / 200,000 entries at most; default false)} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
always,
|s, p| {
if let Some(f) = p.get("folder") {
@@ -1258,12 +1403,15 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
s.import_defaults.auto_copy = c;
}
if let Some(b) = p.get("subfolders").and_then(Value::as_bool) {
s.import_defaults.auto_subfolders = b;
}
if let Some(a) = p.get("album") {
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
}
s.save_prefs()?;
let d = &s.import_defaults;
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album, "subfolders": d.auto_subfolders}))
}
),
cmd!(
@@ -1271,10 +1419,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Auto Import Now",
[],
None,
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), listingWarnings?: [text] (what that listing left out), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan; with subfolders on, its subfolders' photos too) → {imported, folder, subfolders, warnings?: [{path, code, profile?, mode?, fields?}], listingWarnings?: [text] (an unreadable subfolder, a listing bound reached), import?}",
always,
|s, p| {
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
let Some(walk) = AutoImportWalk::of(s) else {
return Ok(json!({"imported": [], "folder": null, "subfolders": s.import_defaults.auto_subfolders}));
};
let (folder, subfolders) = (walk.folder.clone(), walk.subfolders);
// only files that stopped growing: a file still being written is left for later
let known: std::collections::HashSet<String> = s
.catalog
@@ -1285,9 +1436,21 @@ pub fn specs() -> Vec<CommandSpec> {
})
.collect();
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
let (listing, list_warnings): (Vec<(String, u64)>, Vec<String>) = match p.get("listing").and_then(Value::as_array) {
Some(l) => {
(l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(), strs(p, "listingWarnings"))
}
None => {
let l = walk.list().map_err(|e| bad("library.autoImportScan", e))?;
(l.files, l.warnings)
}
};
let base = |imported: Value| {
let mut r = json!({"imported": imported, "folder": folder, "subfolders": subfolders});
if !list_warnings.is_empty() {
r["listingWarnings"] = json!(list_warnings);
}
r
};
let mut fresh = Vec::new();
for (ps, size) in listing {
@@ -1304,7 +1467,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
if fresh.is_empty() {
return Ok(json!({"imported": [], "folder": folder}));
return Ok(base(json!([])));
}
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
// a photo deleted from the library doesn't come back on its own because its file
@@ -1321,13 +1484,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
if p.get("start").and_then(Value::as_bool) == Some(false) {
// the caller imports them (the app: on a worker thread)
return Ok(json!({"imported": [], "folder": folder, "import": params}));
let mut r = base(json!([]));
r["import"] = params;
return Ok(r);
}
let sel = s.selection.clone();
let r = s.execute("library.import", &params)?;
// arriving photos don't take over the selection
s.selection = sel;
let mut result = json!({"imported": r["imported"], "folder": folder});
let mut result = base(r["imported"].clone());
if let Some(warnings) = r.get("warnings") {
result["warnings"] = warnings.clone();
}
+121
View File
@@ -472,6 +472,117 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// The thumbnail cache folder: where it is, the library's default, whether it is custom, what it
/// holds, whether it can be used now (a chosen folder on an unplugged drive is `available: false`).
#[cfg(not(target_arch = "wasm32"))]
fn thumbs_location_json(s: &Session) -> Value {
let dir = s.thumb_cache_dir();
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.thumbs_dir());
let (count, bytes) = dir.as_deref().map_or((0, 0), lightcraft_preview::disk::cache_stats);
json!({
"path": dir.as_ref().map(|d| d.to_string_lossy()),
"default": default.map(|d| d.to_string_lossy().to_string()),
"custom": s.thumbs_dir.is_some(),
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.thumbs_dir.is_none(),
"count": count,
"bytes": bytes,
})
}
/// Apply `what` to the cached thumbnails in `from` for the new folder `to` → (moved or removed,
/// failed). Only the cache's own files are touched (`lightcraft_preview::disk::cache_files`).
#[cfg(not(target_arch = "wasm32"))]
fn thumbs_migrate(from: &std::path::Path, to: &std::path::Path, what: crate::smart::Existing) -> (usize, Vec<String>) {
use crate::smart::Existing;
let (mut done, mut failed) = (0, Vec::new());
if what == Existing::Leave {
return (done, failed);
}
for (src, _, _) in lightcraft_preview::disk::cache_files(from) {
let r = match what {
Existing::Discard => std::fs::remove_file(&src),
_ => {
let (Some(name), Some(shard)) = (src.file_name(), src.parent().and_then(|p| p.file_name())) else { continue };
let dir = to.join(shard);
let dst = dir.join(name);
// create only the shard, never missing parents on an unplugged drive
let prepare = if dir.is_dir() { Ok(()) } else { std::fs::create_dir(&dir) };
// keyed by content: one already there is the same thumbnail; across drives a copy
// goes through a temp file (the cache never sees a partial one)
prepare.and_then(|()| {
if dst.is_file() {
std::fs::remove_file(&src)
} else {
std::fs::rename(&src, &dst).or_else(|_| {
std::fs::read(&src)
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b))
.and_then(|()| std::fs::remove_file(&src))
})
}
})
}
};
match r {
Ok(()) => done += 1,
Err(err) => failed.push(format!("{}: {err}", src.display())),
}
}
(done, failed)
}
/// Show or change where the thumbnail cache is kept (per library), like
/// [`smart_location`]: no fallback to another drive, and an explicit choice for what is cached.
#[cfg(not(target_arch = "wasm32"))]
fn thumbs_location(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.thumbnailCacheLocation";
let current = s.thumb_cache_dir().ok_or_else(|| bad(C, "the thumbnail cache folder needs a library on disk"))?;
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
if !reset && chosen.is_none() {
return Ok(thumbs_location_json(s));
}
let default = s
.library
.as_ref()
.filter(|l| l.on_disk)
.map(|l| l.thumbs_dir())
.ok_or_else(|| bad(C, "the thumbnail cache folder needs a library on disk"))?;
let (new_dir, custom) = match chosen {
Some(x) if !reset => {
let d = std::path::PathBuf::from(x.trim());
if !d.is_absolute() {
return Err(bad(C, "path must be an absolute folder path"));
}
(d.clone(), (d != default).then_some(d))
}
_ => (default, None),
};
if new_dir == current {
return Ok(thumbs_location_json(s));
}
// the new place must work before anything moves; there is no fallback to another drive
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
let (held, _) = lightcraft_preview::disk::cache_stats(&current);
let existing = match str_param(p, "existing") {
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
None if held == 0 => Some(crate::smart::Existing::Leave),
None => None,
};
let Some(existing) = existing else {
return Err(bad(C, format!("{held} cached thumbnail(s) are in {}: pass existing = move, leave or discard", current.display())));
};
let (moved, failed) = thumbs_migrate(&current, &new_dir, existing);
s.thumbs_dir = custom;
// the old cache is retired (render jobs holding it can't write there any more); the new
// folder fills as thumbnails are rendered
s.media.attach_disk_cache(&new_dir, s.cache_bytes(), s.thumbs_dir.is_some());
s.save_prefs()?;
let mut r = thumbs_location_json(s);
r["handled"] = json!(moved);
r["failed"] = json!(failed);
Ok(r)
}
/// Whether photo `id` has a smart preview.
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
match (&s.media.smart_dir, s.catalog.photo(id)) {
@@ -502,6 +613,16 @@ pub fn specs() -> Vec<CommandSpec> {
always,
smart_location
),
#[cfg(not(target_arch = "wasm32"))]
cmd!(
"library.thumbnailCacheLocation",
"Thumbnail Cache Location",
[],
None,
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its thumbnail cache → {path, default, custom, available, count, bytes}. With path (or reset = back to `thumbs` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive; a chosen folder missing when the library opens is reported and thumbnails stay in memory). Thumbnails already in the old folder need `existing`: move them, leave them (the new folder fills as the grid renders), or discard them → also {handled, failed}",
always,
thumbs_location
),
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
+6 -2
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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]]}",
"{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]], warnings?: [{path, code, mode?, fields?}] (what sidecars said that couldn't be carried over, as for library.import)}",
always,
sync
),
@@ -57,5 +57,9 @@ fn sync(s: &mut Session, p: &Value) -> Result<Value> {
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}))
let mut out = json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed});
if !r.warnings.is_empty() {
out["warnings"] = json!(r.warnings);
}
Ok(out)
}
+228 -14
View File
@@ -12,7 +12,8 @@
use std::collections::BTreeMap;
use lightcraft_develop::{MIXER_BANDS, Preset};
use lightcraft_develop::{MIXER_BANDS, Preset, Upright};
use lightcraft_geom::{Affine, Homography, Orientation, Point, Rect};
use serde_json::{Map, Value, json};
/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
@@ -421,15 +422,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
n(o, "PerspectiveX", "geometry.offset_x");
n(o, "PerspectiveY", "geometry.offset_y");
if let Some(u) = num(props, "crs:PerspectiveUpright") {
let mode = match u as i64 {
1 => "auto",
2 => "level",
3 => "vertical",
4 => "full",
5 => "guided",
_ => "off",
};
put(o, "geometry.upright", json!(mode));
put(o, "geometry.upright", json!(upright_mode(u).unwrap_or(Upright::Off)));
// a stated mode never keeps a transform solved for another one: it is solved again for this
// photo, unless the photo's frame is known and Lightroom's own saved matrix is placed
// ([`saved_upright`], [`crate::sidecar::SidecarData::set_frame`])
put(o, "geometry.upright_transform", Value::Null);
}
// ---- Crop (normalized edges of the unrotated image + straighten angle)
@@ -445,7 +442,13 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
);
}
}
Some(false) => put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0})),
Some(false) => {
// "no crop": the edges Lightroom keeps writing beside it are read, not lost adjustments
for k in ["Left", "Top", "Right", "Bottom", "Angle"] {
note(&format!("crs:Crop{k}"));
}
put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}))
}
None => {}
}
out
@@ -485,8 +488,11 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
// Point Color slots that are all empty (-1)
let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v));
let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::<f64>().is_ok_and(|x| x == -1.0) || n.trim().is_empty()));
// Upright's analysis record (version, saved matrices, centre, focal length, guides): placed or
// reported by the photo import's `uprightGeometry` diagnostic, never an adjustment of its own
let quiet = |k: &str| {
k.starts_with("Enable")
|| k.starts_with("Upright")
|| k.starts_with("ToneCurveName")
|| k == "AutoTone"
|| k == "AutoGrayscaleMix"
@@ -511,8 +517,8 @@ pub fn camera_profile_name(props: &Props) -> Option<&str> {
first(props, "crs:CameraProfile")
}
/// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a
/// coordinate convention that LightCraft does not currently import.
/// Whether an XMP packet contains a saved Lightroom Upright matrix (usable or not: see
/// [`saved_upright`]).
pub fn has_saved_upright_transform(props: &Props) -> bool {
props.keys().any(|key| {
key.strip_prefix("crs:").is_some_and(|name| {
@@ -521,6 +527,213 @@ pub fn has_saved_upright_transform(props: &Props) -> bool {
})
}
/// The Upright mode a `crs:PerspectiveUpright` value names: 0 Off, 1 Auto, 2 Full, 3 Level,
/// 4 Vertical, 5 Guided (issue #205: the matrix saved for each value has that mode's shape, and
/// ExifTool names the values the same way). `None` for anything else.
pub fn upright_mode(value: f64) -> Option<Upright> {
if value.fract() != 0.0 {
return None;
}
Some(match value as i64 {
0 => Upright::Off,
1 => Upright::Auto,
2 => Upright::Full,
3 => Upright::Level,
4 => Upright::Vertical,
5 => Upright::Guided,
_ => return None,
})
}
/// The Upright mode a packet selects (`crs:PerspectiveUpright`), if it names one.
pub fn selected_upright(props: &Props) -> Option<Upright> {
num(props, "crs:PerspectiveUpright").and_then(upright_mode)
}
/// Whether a packet has Guided Upright guides (`crs:UprightFourSegments_<n>`, or a positive
/// `crs:UprightFourSegmentsCount`). LightCraft doesn't read them.
pub fn has_upright_guides(props: &Props) -> bool {
let stated = |values: &Vec<String>| values.iter().any(|v| !v.trim().is_empty());
props.iter().any(|(key, values)| {
key.strip_prefix("crs:UprightFourSegments_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) && stated(values)
}) || num(props, "crs:UprightFourSegmentsCount").is_some_and(|n| n > 0.0)
}
/// The Upright geometry an import loses unless Lightroom's saved matrix is kept, by the mode the
/// packet selects: saved matrices for any mode but Off (an unreadable mode value is `Unknown`), and
/// Guided Upright's guides. `None`: nothing would be lost (Upright Off, or no Upright record).
pub fn upright_at_risk(props: &Props) -> Option<crate::import::ImportUprightMode> {
use crate::import::ImportUprightMode as M;
let (matrices, guides) = (has_saved_upright_transform(props), has_upright_guides(props));
let stated = props.get("crs:PerspectiveUpright").is_some_and(|v| v.iter().any(|s| !s.trim().is_empty()));
match selected_upright(props) {
Some(Upright::Off) => None,
None if !stated => None,
None => (matrices || guides).then_some(M::Unknown),
Some(Upright::Guided) => (matrices || guides).then_some(M::Guided),
Some(Upright::Auto) => matrices.then_some(M::Auto),
Some(Upright::Full) => matrices.then_some(M::Full),
Some(Upright::Level) => matrices.then_some(M::Level),
Some(Upright::Vertical) => matrices.then_some(M::Vertical),
}
}
/// The `crs:UprightVersion` whose saved matrices were measured against Lightroom's exports (the
/// only one seen, in packets written by Camera Raw 15.0 to 17.2: Lightroom Classic 12 to 14 and
/// the Lightroom desktop app). Matrices of any other version are not read: their convention is
/// unknown.
pub const UPRIGHT_VERSION: u64 = 151_388_160;
/// Lightroom's saved Upright correction for the selected mode (`crs:UprightTransform_<value>`,
/// where value is `crs:PerspectiveUpright`).
///
/// The convention was measured, not assumed (issue #205; three Upright Auto exports of public raws,
/// one of them portrait and one cropped, matched to 0.5 px): a forward homography (uncorrected →
/// corrected, column vectors, `p' = M·p`) in coordinates normalized to the *stored* image (x / width,
/// y / height, origin top left, y down, before the EXIF orientation), applied before the crop, with
/// Lightroom's scale to fill the frame already in it. Guided Upright's slot holds no result (its
/// correction is solved from the guides when drawn), so Guided is never read from here.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SavedUpright {
pub mode: Upright,
/// Row-major, scaled so the last element is 1.
pub matrix: [f64; 9],
}
/// Largest magnitude accepted for an element of a saved matrix (normalized coordinates: a real
/// correction is of order 1).
const UPRIGHT_MAX_ELEMENT: f64 = 100.0;
/// The packet's saved Upright correction for its selected mode, when LightCraft can apply it: a
/// mode with an automatic result (Auto, Full, Level, Vertical), [`UPRIGHT_VERSION`], a
/// `crs:UprightTransformCount` that includes the mode's slot, and nine finite numbers that make a
/// sensible correction (no point of the photo at or beyond the horizon, no mirror, no collapse).
pub fn saved_upright(props: &Props) -> Option<SavedUpright> {
// the slot is the mode's `crs:PerspectiveUpright` value; Off and Guided have no automatic result
let mode = selected_upright(props)?;
let slot = match mode {
Upright::Auto => 1,
Upright::Full => 2,
Upright::Level => 3,
Upright::Vertical => 4,
Upright::Off | Upright::Guided => return None,
};
if first(props, "crs:UprightVersion")?.parse::<u64>().ok()? != UPRIGHT_VERSION {
return None;
}
let count = first(props, "crs:UprightTransformCount")?.parse::<usize>().ok()?;
if count <= slot {
return None;
}
let matrix = parse_upright_matrix(first(props, &format!("crs:UprightTransform_{slot}"))?)?;
Some(SavedUpright { mode, matrix })
}
/// Nine comma-separated numbers as a usable saved Upright matrix (see [`saved_upright`]).
fn parse_upright_matrix(text: &str) -> Option<[f64; 9]> {
let mut m = [0.0; 9];
let mut parts = text.split(',');
for v in &mut m {
*v = parts.next()?.trim().parse::<f64>().ok().filter(|x| x.is_finite())?;
}
if parts.next().is_some() || m[8].abs() < 1e-9 {
return None;
}
let last = m[8];
for v in &mut m {
*v /= last;
}
if m.iter().any(|v| !v.is_finite() || v.abs() > UPRIGHT_MAX_ELEMENT) {
return None;
}
// every point of the photo stays in front of the horizon (w is affine: its corners bound it)
let w = |x: f64, y: f64| m[6] * x + m[7] * y + m[8];
if [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)].iter().any(|&(x, y)| w(x, y) < 1e-3) {
return None;
}
// with w > 0 there, the sign of the determinant is the sign of the local area: no mirror, no collapse
let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]);
(det.is_finite() && det > 1e-6).then_some(m)
}
/// Normalized stored coordinates → normalized oriented coordinates, as a homography.
fn orientation_homography(orientation: Orientation) -> Homography {
let at = |x: f64, y: f64| {
let (u, v) = orientation.map(x, y, 1.0, 1.0);
Point::new(u, v)
};
let (o, ex, ey) = (at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0));
Homography([ex.x - o.x, ey.x - o.x, o.x, ex.y - o.y, ey.y - o.y, o.y, 0.0, 0.0, 1.0])
}
/// Centred oriented coordinates (`(p − centre) / (long edge / 2)`) → normalized oriented
/// coordinates, for an oriented image of `ow × oh`.
fn centred_to_normalized(ow: f64, oh: f64) -> Homography {
let l = ow.max(oh) / 2.0;
Homography([l / ow, 0.0, 0.5, 0.0, l / oh, 0.5, 0.0, 0.0, 1.0])
}
/// A saved Upright correction in LightCraft's frame: the homography of
/// `Geometry::upright_transform` (lens-corrected → transformed, centred coordinates of the oriented
/// image). `orientation` turns the stored image, `stored_w × stored_h` (any unit; only the aspect
/// matters), into LightCraft's oriented image. `None` for a frame without a finite, positive size.
pub fn upright_in_frame(saved: &SavedUpright, orientation: Orientation, stored_w: f64, stored_h: f64) -> Option<Homography> {
let a = centred_to_stored(orientation, stored_w, stored_h)?;
normalized(a.inverse()?.mul(&Homography(saved.matrix)).mul(&a))
}
/// Centred coordinates of the oriented image → normalized coordinates of the stored image.
fn centred_to_stored(orientation: Orientation, stored_w: f64, stored_h: f64) -> Option<Homography> {
if !(stored_w.is_finite() && stored_h.is_finite() && stored_w > 0.0 && stored_h > 0.0) {
return None;
}
let (ow, oh) = if orientation.swaps_axes() { (stored_h, stored_w) } else { (stored_w, stored_h) };
Some(orientation_homography(orientation).inverse()?.mul(&centred_to_normalized(ow, oh)))
}
/// `h` scaled so its last element is 1, when that leaves it finite.
fn normalized(h: Homography) -> Option<Homography> {
let last = h.0[8];
let h = Homography(h.0.map(|v| v / last));
h.0.iter().all(|v| v.is_finite()).then_some(h)
}
/// Put a placed Upright correction ([`upright_in_frame`]) into a partial settings object.
pub fn put_upright_transform(partial: &mut Value, h: Homography) {
put(partial, "geometry.upright_transform", json!(h));
}
/// Lightroom's crop in LightCraft's frame (Lightroom catalogs and, given the photo's frame, XMP
/// sidecars: [`crate::sidecar::SidecarData::set_frame`]). `crs` gives the crop's top-left (`CropLeft`, `CropTop`)
/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and
/// straightening), normalized to its size, plus a straighten angle that turns the other way to
/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its
/// centre, normalized to the oriented image's size, so the corners go through the orientation and
/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the
/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops
/// in Lightroom's exports within 1%.
pub fn crop_in_frame(partial: &mut Value, orientation: Orientation, stored_aspect: f64) {
let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return };
let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64);
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return };
let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
if orientation == Orientation::Normal && lightroom_angle == 0.0 {
return;
}
let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 };
let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) };
// a mirror turns the straighten the other way too
let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle };
let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
let corner = |x: f64, y: f64| {
let (u, v) = orientation.map(x * w, y, w, 1.0);
to_rotated.apply(Point::new(u, v))
};
let r = Rect::from_points(corner(x0, y0), corner(x1, y1));
geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh}));
geometry.insert("angle".into(), json!(angle));
}
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
/// Returns `None` when the packet has no adjustments we understand.
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
@@ -604,7 +817,8 @@ mod tests {
assert_eq!(s.vignette.amount, -21.0);
assert_eq!(s.vignette.style, VignetteStyle::ColorPriority);
assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0));
assert_eq!(s.geometry.upright, Upright::Level);
// Lightroom's 2 is Full (issue #205), not Level
assert_eq!(s.geometry.upright, Upright::Full);
assert_eq!(s.geometry.vertical, -10.0);
assert!(s.optics.remove_ca);
assert_eq!(s.treatment, lightcraft_develop::Treatment::Color);
+19
View File
@@ -152,6 +152,25 @@ mod tests {
}
}
/// Alt-drag previews on a profiled display (issue #937): the histogram stays the photo's (not
/// the preview's), and the clipping view is tested and drawn in sRGB, so it stays black and white.
#[test]
fn diagnostic_views_keep_the_photos_histogram() {
use lightcraft_pipeline::Overlay;
let mut session = crate::Session::with_demo();
let id = session.visible_cloned()[0];
let d = std::sync::Arc::new(p3());
let mut run = |o| session.loupe_job(id, 320, 240, true).unwrap().with_display(Some(d.clone())).with_overlay(o).run().rendered.unwrap();
let plain = run(Overlay::None);
let clip = run(Overlay::ClipShadows);
let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma());
assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}");
let px = &clip.image.data;
// (the profile may round white to 254)
let binary = px.iter().filter(|p| p[..3].iter().all(|v| *v <= 2 || *v >= 253)).count();
assert!(binary as f64 > 0.99 * px.len() as f64, "{binary} of {}", px.len());
}
#[test]
fn load_reports_and_rejects() {
let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id()));
+4 -4
View File
@@ -1734,10 +1734,10 @@ impl PreparedExport {
};
let Some(dng) = dng else {
// the file is copied byte for byte: refuse rather than leak a location it embeds
if remove_location && lightcraft_meta::extract(&bytes).gps.is_some() {
return Err(format!(
"{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead"
));
if remove_location && lightcraft_meta::embeds_location(&bytes) {
// DNG only for what the DNG export below takes
let instead = if lightcraft_raw::probe(&bytes).is_some() { "JPEG, TIFF or DNG" } else { "JPEG or TIFF" };
return Err(format!("{label}: can't remove the location from an original file that embeds GPS; export as {instead} instead"));
}
return Ok(Exported {
file_name,
+4 -2
View File
@@ -182,6 +182,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: None,
orientation: Some(raw.orientation),
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@@ -215,6 +216,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
embedded_lens: None,
xmp: None,
preview_only: None,
orientation: Some(o),
})
}
@@ -2171,8 +2173,8 @@ mod tests {
}
}
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first
/// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing.
#[test]
fn undecodable_container_with_a_preview_imports_as_preview_only() {
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
+15 -6
View File
@@ -32,10 +32,11 @@ use serde::{Deserialize, Serialize};
use crate::Session;
use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
/// File extensions offered by import discovery and file pickers (lower case).
/// This includes preview-only raws; the file probe decides whether sensor decoding is available.
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",
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef",
"srw", "psd", "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
@@ -310,6 +311,8 @@ pub struct ImportDefaults {
pub auto_folder: Option<String>,
pub auto_copy: bool,
pub auto_album: Option<String>,
/// Auto import also looks in the watched folder's subfolders (off: its top level only).
pub auto_subfolders: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -356,7 +359,8 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
match preset {
Some(pr) => {
let look = std::sync::Arc::new(pr.apply(&base, 1.0));
// a preset carries no Upright correction (an old one may still hold one: not this photo's)
let look = std::sync::Arc::new(crate::upright_transfer::apply_preset(p, &base, pr, 1.0));
p.develop = look.clone();
p.import_look = (*look != base).then_some(look);
}
@@ -983,7 +987,11 @@ impl ImportJob {
.or_else(|| info.xmp.clone().filter(|_| raw));
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
Ok(sc) => Some(sc),
Ok(mut sc) => {
// Lightroom's crop and saved Upright refer to the file's stored image
sc.set_frame(info.stored_frame());
Some(sc)
}
Err(e) => {
log::warn!("import {path}: XMP: {e}");
None
@@ -1353,7 +1361,8 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
}
}
if let Some(preset) = &opts.preset {
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
// after the sidecar: the photo keeps the Upright correction it was given
let d = std::sync::Arc::new(crate::upright_transfer::apply_preset(&p, &p.develop, preset, 1.0));
let label = format!("Preset: {}", preset.name);
p.develop = d.clone();
p.edited = Some(now.clone());
+99 -16
View File
@@ -46,6 +46,7 @@ mod lightroom_archive;
pub mod lightroom_catalog;
pub mod lightroom_folder;
pub mod lightroom_job;
mod lightroom_smart;
mod lightroom_sqlite;
pub mod logging;
pub mod media;
@@ -58,10 +59,12 @@ pub mod preset_luminar;
pub mod presets;
pub mod proof;
pub mod rename;
pub mod render_dng;
pub mod segment;
pub mod sidecar;
pub mod smart;
pub mod sync;
pub mod upright_transfer;
mod view;
pub mod walk;
@@ -176,15 +179,16 @@ pub struct Session {
pub filter: Filter,
pub sort: Sort,
pub selection: Selection,
/// The grid order for the current source/filter/sort (cached by catalog revision).
/// The grid order for the current source/filter/sort (cached by library revision, and by the
/// full catalog revision when the filter, sort or source reads develop state).
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
visible: Arc<[PhotoId]>,
visible_key: Option<(u64, String)>,
visible_key: Option<ViewKey>,
/// Identifies the current `visible` list: a new value (unique in the process) every time it
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
/// Photos in the current source with no filter on (cached like `visible`).
total: Option<((u64, String), usize)>,
total: Option<(ViewKey, 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`]).
@@ -211,6 +215,9 @@ pub struct Session {
pub(crate) faces: faces_index::FacesState,
/// Copied develop settings (partial JSON) for Paste.
pub clipboard: Option<Value>,
/// The photo the clipboard was copied from (an Upright correction moves only between entries of
/// the same file: [`crate::upright_transfer`]).
pub clipboard_source: Option<crate::upright_transfer::UprightSource>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
pub browse: Option<Browse>,
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
@@ -222,7 +229,8 @@ pub struct Session {
pub previous_active: Option<PhotoId>,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// Built-in then user presets; its generation tells savers and caches when it changed.
pub presets: presets::PresetList,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec<String>,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
@@ -293,6 +301,9 @@ pub struct Session {
pub cache_mb: u32,
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
pub smart_previews_dir: Option<std::path::PathBuf>,
/// Where the thumbnail cache is kept when not in the library's `thumbs` folder (persisted in
/// prefs.json; see [`Session::thumb_cache_dir`]).
pub thumbs_dir: Option<std::path::PathBuf>,
/// Untouched Local records of folders not browsed for this many days are forgotten when the
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
pub forget_local_days: u32,
@@ -350,12 +361,13 @@ impl Session {
#[cfg(not(target_arch = "wasm32"))]
faces: Default::default(),
clipboard: None,
clipboard_source: None,
meta_clipboard: None,
browse: None,
library_folder: None,
previous_active: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
presets: presets::builtin(),
presets: presets::PresetList::new(presets::builtin()),
profile_favorites: Vec::new(),
profile_recent: Vec::new(),
journal: Vec::new(),
@@ -390,6 +402,7 @@ impl Session {
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
smart_previews_dir: None,
thumbs_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
apple_photos: None,
@@ -785,18 +798,30 @@ impl Session {
return Vec::new();
}
}
// an Upright correction moves only between entries of the same file
let source = self.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, new, &self.library_identity));
self.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
let synced = match self.catalog.photo(x) {
Some(p) => crate::upright_transfer::transferred(p, &d, &delta, source.as_ref()),
None => lightcraft_develop::apply_partial(&d, &delta, 1.0),
};
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
}
/// What `preset` at `amount` makes of photo `id`'s settings, as Apply Preset stores it (its
/// Upright correction settled: [`crate::upright_transfer::apply_preset`]); for previews too.
pub fn preset_look(&self, id: PhotoId, preset: &lightcraft_develop::Preset, amount: f64) -> Option<DevelopSettings> {
let p = self.catalog.photo(id)?;
Some(crate::upright_transfer::apply_preset(p, &p.develop, preset, amount))
}
/// The op that sets a photo's develop settings and appends a History entry (for batches).
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
self.catalog.photo(id)?;
@@ -840,13 +865,14 @@ impl Session {
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
let mut key =
(self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder));
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
}
if self.visible_key.as_ref() != Some(&key) {
// a develop edit (a slider tick) only redoes the query when the filter, sort or source reads develop state
let reads_develop = self.source_reads_develop() || self.sort.reads_develop() || self.catalog.filter_reads_develop(&self.filter);
let revision = self.catalog.view_revision(reads_develop);
// the Missing Photos view fills in as the background checks find files gone
let availability =
(self.source == LibrarySource::Missing && self.media.availability.is_background()).then(|| self.media.availability.generation());
if !self.visible_key.as_ref().is_some_and(|k| k.is(self, revision, true, availability)) {
let key = ViewKey::of(self, revision, true, availability);
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
@@ -923,8 +949,9 @@ impl Session {
if self.source == LibrarySource::Missing {
return None;
}
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
let revision = self.catalog.view_revision(self.source_reads_develop());
if !self.total.as_ref().is_some_and(|t| t.0.is(self, revision, false, None)) {
let key = ViewKey::of(self, revision, false, None);
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
if self.source == LibrarySource::Folder {
let b = self.browse.clone().unwrap_or_default();
@@ -940,12 +967,21 @@ impl Session {
self.total.as_ref().map(|t| t.1)
}
/// Whether what the current source holds can change with a develop edit (a smart album
/// whose rules read develop state; see [`lightcraft_catalog::revision`]).
fn source_reads_develop(&self) -> bool {
match self.source {
LibrarySource::Album(a) => self.catalog.album_reads_develop(a),
_ => false,
}
}
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
self.visible().to_vec()
}
/// The visible photos without copying them, with their generation: equal generations mean
/// the very same list (same catalog revision, source, filter, sort), so a frontend can cache
/// the very same list (same library, source, filter, sort), so a frontend can cache
/// whatever it derives from the list under that number.
pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) {
self.visible();
@@ -964,6 +1000,45 @@ impl Session {
}
}
/// What [`Session::visible`] (or [`Session::source_total`]) was worked out from. Compared every
/// frame, so without formatting or copying anything.
#[derive(Clone, Debug, PartialEq)]
struct ViewKey {
revision: lightcraft_catalog::ViewRevision,
source: LibrarySource,
/// The filter bar and sort (`None` for the source total, which ignores them).
view: Option<(Filter, Sort)>,
browse: Option<Browse>,
library_folder: Option<String>,
availability: Option<u64>,
}
impl ViewKey {
fn of(s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> ViewKey {
ViewKey {
revision,
source: s.source,
view: filtered.then(|| (s.filter.clone(), s.sort)),
browse: s.browse.clone(),
library_folder: s.library_folder.clone(),
availability,
}
}
fn is(&self, s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> bool {
let view = match &self.view {
Some((f, sort)) => filtered && *f == s.filter && *sort == s.sort,
None => !filtered,
};
self.revision == revision
&& self.source == s.source
&& view
&& self.browse == s.browse
&& self.library_folder == s.library_folder
&& self.availability == availability
}
}
/// Automatic versions kept per photo.
pub const AUTO_VERSIONS: usize = 20;
@@ -987,6 +1062,8 @@ mod tests_album_order;
#[cfg(test)]
mod tests_albums_local;
#[cfg(test)]
mod tests_auto_import;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_crop_angle;
@@ -1012,6 +1089,8 @@ mod tests_import;
#[cfg(test)]
mod tests_import_move;
#[cfg(test)]
mod tests_invalidation;
#[cfg(test)]
mod tests_keyword_list;
#[cfg(test)]
mod tests_libops;
@@ -1028,6 +1107,8 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_presets_scale;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
@@ -1038,4 +1119,6 @@ mod tests_spots;
#[cfg(test)]
mod tests_sync;
#[cfg(test)]
mod tests_upright;
#[cfg(test)]
mod tests_xmp;
+96 -16
View File
@@ -6,7 +6,7 @@
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/sort/selection)
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! thumbs/ (rendered thumbnail cache, safe to delete; prefs.json may name another folder)
//! Originals/ (photos imported with "copy into library")
//! ```
//!
@@ -88,7 +88,8 @@ pub struct Library {
retry_at: Option<web_time::Instant>,
/// What forgetting untouched Local records did when the library opened.
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
presets_written: String,
/// What presets.json holds (see [`PresetsSaved`]).
presets_saved: PresetsSaved,
view_written: Vec<u8>,
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
@@ -123,6 +124,53 @@ struct PresetsFile {
profile_recent: Vec<String>,
}
/// The state presets.json was last written (or read) from. Saving checks the preset list's
/// generation and the small profile lists before serializing anything: with a 10 000-preset
/// collection presets.json is ~40 MB, and serializing it after every command made each slider
/// tick take seconds.
#[derive(Default)]
struct PresetsSaved {
generation: u64,
profile_favorites: Vec<String>,
profile_recent: Vec<String>,
/// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten.
hash: u64,
}
impl PresetsSaved {
fn state_of(s: &Session) -> PresetsSaved {
PresetsSaved {
generation: s.presets.generation(),
profile_favorites: s.profile_favorites.clone(),
profile_recent: s.profile_recent.clone(),
hash: 0,
}
}
/// `s` changed its presets or profile lists since this state.
fn differs(&self, s: &Session) -> bool {
self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent
}
}
fn bytes_hash(b: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut h);
h.finish()
}
#[cfg(test)]
thread_local! {
static PRESETS_SERIALIZED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread serialized the preset list (tests).
#[cfg(test)]
pub(crate) fn presets_serialized() -> usize {
PRESETS_SERIALIZED.with(|c| c.get())
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct ViewFile {
@@ -144,6 +192,7 @@ impl Library {
pub(crate) fn journal_mut(&mut self) -> &mut Journal {
&mut self.journal
}
/// The library's own thumbnail cache folder (the default; see [`Session::thumb_cache_dir`]).
pub fn thumbs_dir(&self) -> PathBuf {
self.dir.join("thumbs")
}
@@ -181,12 +230,16 @@ struct PrefsFile {
cache_mb: u32,
/// Folder for smart previews (default: `Smart Previews` in the library).
smart_previews_dir: Option<String>,
/// Folder for the thumbnail cache (default: `thumbs` in the library).
thumbs_dir: Option<String>,
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
/// the default, 0 = never).
forget_local_days: Option<u32>,
}
fn presets_json(s: &Session) -> String {
#[cfg(test)]
PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1));
let presets = &s.presets;
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
@@ -370,7 +423,7 @@ impl Session {
// in this process its own range: otherwise an empty library opened after one that was
// never edited this session would look unchanged and show the old library's numbers.
static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32;
catalog.set_revision(LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32);
self.catalog = catalog;
self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
@@ -409,13 +462,23 @@ impl Session {
if let Some(d) = &self.smart_previews_dir {
self.media.smart_dir = Some(d.clone());
}
self.thumbs_dir = prefs.thumbs_dir.filter(|_| on_disk).map(PathBuf::from);
// a chosen folder that is gone is reported, and stays the cache folder: thumbnails are
// then kept in memory only (nothing is written to the library drive instead)
if let Some(d) = self.thumbs_dir.as_ref().filter(|d| !d.is_dir()) {
settings.warnings.push(format!(
"The thumbnail cache folder {} is not available (drive disconnected?): thumbnails are kept in memory only until it is back or you choose another folder in Settings",
d.display()
));
}
// presets
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
for p in &mut self.presets {
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
}
let mut ids: std::collections::HashSet<String> = self.presets.iter().map(|p| p.id.clone()).collect();
for u in f.user {
if !self.presets.iter().any(|p| p.id == u.id) {
if ids.insert(u.id.clone()) {
self.presets.push(u);
}
}
@@ -447,11 +510,13 @@ impl Session {
{
self.selection = Selection::single(*first);
}
let presets_written = presets_json(self);
// presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes)
let presets_saved = PresetsSaved::state_of(self);
// photo ids are per library: drop decoded sources of the previous one
self.media.clear_sources();
if on_disk {
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
let thumbs = self.thumbs_dir.clone().unwrap_or_else(|| dir.join("thumbs"));
self.media.attach_disk_cache(&thumbs, self.cache_bytes(), self.thumbs_dir.is_some());
}
let view_written = self.view_json();
self.library = Some(Library {
@@ -464,7 +529,7 @@ impl Session {
unsaved_error: None,
retry_at: None,
forgot_local: None,
presets_written,
presets_saved,
view_written,
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
warnings_reported: 0,
@@ -526,14 +591,21 @@ impl Session {
log::error!("library: compaction: {e}");
lib.last_error = Some(format!("compaction: {e}"));
}
let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json"));
if !due {
return Ok(());
}
let presets = presets_json(self);
let mut state = PresetsSaved::state_of(self);
state.hash = bytes_hash(presets.as_bytes());
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
log::error!("library: presets: {e}");
} else {
lib.presets_written = presets;
}
if state.hash == lib.presets_saved.hash {
lib.presets_saved = state;
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
// the state stays stale: the next save tries again
log::error!("library: presets: {e}");
} else {
lib.presets_saved = state;
}
Ok(())
}
@@ -639,6 +711,7 @@ impl Session {
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
thumbs_dir: self.thumbs_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
forget_local_days: Some(self.forget_local_days),
})
.unwrap_or_default();
@@ -660,6 +733,14 @@ impl Session {
new
}
/// Where the open library keeps its thumbnail cache: the chosen folder
/// ([`Session::thumbs_dir`], which may be unavailable), else `thumbs` in the library. `None`
/// without a library on disk.
pub fn thumb_cache_dir(&self) -> Option<PathBuf> {
let lib = self.library.as_ref().filter(|l| l.on_disk)?;
Some(self.thumbs_dir.clone().unwrap_or_else(|| lib.thumbs_dir()))
}
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
pub fn cache_bytes(&self) -> u64 {
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
@@ -670,9 +751,8 @@ impl Session {
/// Saved with the library preferences.
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
self.cache_mb = mb;
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
let dir = lib.thumbs_dir();
self.media.set_disk_cache_bytes(&dir, self.cache_bytes());
if let Some(dir) = self.thumb_cache_dir() {
self.media.set_disk_cache_bytes(&dir, self.cache_bytes(), self.thumbs_dir.is_some());
}
self.save_prefs()
}
+2
View File
@@ -523,6 +523,7 @@ mod tests {
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
stacks: Vec::new(),
warnings: Vec::new(),
}
}
@@ -545,6 +546,7 @@ mod tests {
collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])],
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
stacks: Vec::new(),
warnings: vec!["one warning".into()],
}
}
+332 -59
View File
@@ -8,9 +8,9 @@ use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue};
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
use lightcraft_geom::{Affine, Orientation, Point, Rect};
use lightcraft_geom::Orientation;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -154,6 +154,9 @@ pub struct CatalogImport {
pub collections: Vec<Row>,
pub members: Vec<Row>,
pub collection_content: Vec<Row>,
/// `AgLibraryFolderStackImage` rows (`stack`, `image`, `position`, `collapsed`).
#[serde(default)]
pub stacks: Vec<Row>,
pub warnings: Vec<String>,
}
@@ -368,22 +371,22 @@ pub fn read(path: &Path) -> Result<CatalogImport, String> {
}
pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result<CatalogImport, String> {
// 13 catalog tables plus one validation stage; ImportJob adds one unit per original path.
total.store(14, Ordering::Relaxed);
// 14 catalog tables plus one validation stage; ImportJob adds one unit per original path.
total.store(15, Ordering::Relaxed);
done.store(0, Ordering::Relaxed);
ensure_read_active(cancel)?;
let before = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_path = PathBuf::from(format!("{}-wal", path.display()));
let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
let main = bytes(path)?;
let main = FileSource::open(path)?;
let wal = match std::fs::metadata(&wal_path) {
Ok(_) => bytes(&wal_path)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.to_string()),
};
let wal_len = committed_wal_len(&main, &wal)?;
let wal_len = committed_wal_len(main.header(), &wal)?;
ensure_read_active(cancel)?;
let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
let after = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after {
@@ -474,6 +477,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
collections: optional_table("AgLibraryCollection", &mut warnings)?,
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
stacks: optional_table("AgLibraryFolderStackImage", &mut warnings)?,
warnings,
};
for image in table("Adobe_images", true)? {
@@ -509,6 +513,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
});
}
ensure_read_active(cancel)?;
reader.db.ensure_unchanged()?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if wal_before != wal_after {
return Err("catalog changed during reading; close Lightroom and retry".into());
}
Ok(out)
}
@@ -533,7 +542,13 @@ fn mapped_settings(
props.insert("crs:HasCrop".into(), vec!["True".into()]);
}
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
catalog_crop(&mut partial, orientation, stored_aspect);
crate::crs::crop_in_frame(&mut partial, orientation, stored_aspect);
// Lightroom's saved Upright isn't kept from catalogs yet: how a catalog encodes it, and whether
// its orientation agrees with the decoded file, haven't been checked on a real catalog (issue
// #205). The mode is solved again, and the report says what was lost.
if let Some(mode) = crate::crs::upright_at_risk(&props) {
unknown.push(catalog_upright_note(mode));
}
if deferred > 0 {
unknown.push("Deferred Lightroom adjustments (-999999)".into());
}
@@ -563,34 +578,25 @@ fn lightroom_orientation(code: &str) -> Option<Orientation> {
ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)]))
}
/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`)
/// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and
/// straightening), normalized to its size, plus a straighten angle that turns the other way to
/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its
/// centre, normalized to the oriented image's size, so the corners go through the orientation and
/// then the rotation; `stored_aspect` (width / height of the stored image) matters once the
/// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops
/// in Lightroom's exports within 1%.
fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f64) {
let Some(geometry) = partial.pointer_mut("/crop/geometry").and_then(Value::as_object_mut) else { return };
let edge = |k: &str| geometry.get("rect").and_then(|r| r.get(k)).and_then(Value::as_f64);
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (edge("x0"), edge("y0"), edge("x1"), edge("y1")) else { return };
let lightroom_angle = geometry.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
if orientation == Orientation::Normal && lightroom_angle == 0.0 {
return;
/// How a catalog import reports Lightroom Upright geometry it doesn't carry over.
fn catalog_upright_note(mode: crate::import::ImportUprightMode) -> String {
match mode {
crate::import::ImportUprightMode::Guided => "Upright (Guided: Lightroom's guides aren't read)".into(),
_ => "Upright (Lightroom's saved geometry, solved again: not read from catalogs yet)".into(),
}
let w = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { 1.0 };
let (ow, oh) = if orientation.swaps_axes() { (1.0, w) } else { (w, 1.0) };
// a mirror turns the straighten the other way too
let angle = if orientation.to_parts().0 { lightroom_angle } else { -lightroom_angle };
let to_rotated = Affine::rotate_about(angle.to_radians(), Point::new(ow / 2.0, oh / 2.0));
let corner = |x: f64, y: f64| {
let (u, v) = orientation.map(x * w, y, w, 1.0);
to_rotated.apply(Point::new(u, v))
};
let r = Rect::from_points(corner(x0, y0), corner(x1, y1));
geometry.insert("rect".into(), json!({"x0": r.x0 / ow, "y0": r.y0 / oh, "x1": r.x1 / ow, "y1": r.y1 / oh}));
geometry.insert("angle".into(), json!(angle));
}
/// The catalog report's names for what an XMP packet's import diagnostics say wasn't carried over.
fn catalog_notes(warnings: &[crate::import::ImportWarning]) -> Vec<String> {
use crate::import::ImportWarningCode as C;
warnings
.iter()
.flat_map(|w| match w.code {
C::CameraProfile => vec!["CameraProfile".to_string()],
C::UprightGeometry => vec![catalog_upright_note(w.mode.unwrap_or(crate::import::ImportUprightMode::Unknown))],
C::UnmappedXmp => w.fields.clone(),
})
.collect()
}
fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize {
@@ -760,12 +766,17 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
if keep_develop {
preserved += 1;
}
// what the XMP and the develop settings couldn't carry over, each named once
let mut notes: Vec<String> = Vec::new();
if !src.xmp.is_empty() {
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);
}
if !keep_develop {
notes.extend(catalog_notes(&sc.warnings));
}
crate::sidecar::merge_into(&mut p, &sc, &now);
}
Err(e) => warnings.push(format!("image {} XMP: {e}", src.source_id)),
@@ -797,13 +808,16 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
Ok((partial, unknown)) => {
p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0));
p.edited = Some(now.clone());
if !unknown.is_empty() {
unmapped.insert(src.source_id, unknown);
}
notes.extend(unknown);
}
Err(e) => warnings.push(format!("image {} settings: {e}", src.source_id)),
}
}
let mut seen = std::collections::HashSet::new();
notes.retain(|n| seen.insert(n.clone()));
if !notes.is_empty() {
unmapped.insert(src.source_id, notes);
}
if keep_develop {
(p.develop, p.edited) = previous_develop;
}
@@ -846,6 +860,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
for members in grouped_members.values_mut() {
members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection")));
}
let smart_content: HashMap<i64, &str> = data
.collection_content
.iter()
.filter(|r| text(r, "owningModule") == "ag.library.smart_collection")
.map(|r| (number(r, "collection"), text(r, "content")))
.collect();
let (mut smart_albums, mut static_smart, mut smart_kept) = (0usize, 0usize, 0usize);
let mut ordered: Vec<_> = data.collections.iter().collect();
ordered.sort_by_key(|r| {
let mut depth = 0;
@@ -861,47 +882,116 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let mut album = Album::new(id, text(collection, "name"));
album.parent = albums.get(&number(collection, "parent")).copied();
album.folder = text(collection, "creationId").contains("group");
if text(collection, "creationId").contains("smart") {
warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name));
}
if !album.folder {
album.photos = grouped_members
.get(&number(collection, "id_local"))
.into_iter()
.flatten()
.filter_map(|r| ids.get(&number(r, "image")).copied())
.collect();
}
if s.catalog.album(id).is_some() {
// Reimport preserves the user's album name/hierarchy, merges only new membership.
if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) {
let mut photos = old.photos.clone();
let is_smart = text(collection, "creationId").contains("smart");
let rules = if is_smart {
Some(match smart_content.get(&number(collection, "id_local")) {
Some(c) => crate::lightroom_smart::smart_rules(c),
None => Err("smart rules missing".into()),
})
} else {
None
};
let members: Vec<PhotoId> = grouped_members
.get(&number(collection, "id_local"))
.into_iter()
.flatten()
.filter_map(|r| ids.get(&number(r, "image")).copied())
.collect();
// Reimport branches on the existing album's kind: its name/hierarchy and (for a smart album)
// its possibly user-edited rules are kept; a regular album only gains new members.
let existing = s.catalog.album(id).map(|a| (a.folder, a.is_smart(), a.photos.clone()));
match existing {
Some((true, _, _)) => {}
Some((false, true, _)) => {
if is_smart {
smart_albums += 1;
smart_kept += 1;
}
}
Some((false, false, old)) => {
if let Some(rules) = &rules {
static_smart += 1;
match rules {
Ok(_) => warnings.push(format!(
"{}: an earlier import created a static album; it stays static (delete it and reimport to get a smart album)",
album.name
)),
Err(why) => warnings.push(format!("{}: {why}; imported current membership (rules kept in archive)", album.name)),
}
}
let mut photos = old;
let mut seen: HashSet<_> = photos.iter().copied().collect();
for photo in album.photos {
for photo in members {
if seen.insert(photo) {
photos.push(photo);
}
}
ops.push(Op::SetAlbumPhotos { id, photos });
}
} else {
ops.push(Op::AddAlbum { album });
None => {
match rules {
Some(Ok(rules)) => {
album.smart = Some(Box::new(lightcraft_catalog::Filter { rule_set: Some(rules), ..Default::default() }));
smart_albums += 1;
}
Some(Err(why)) => {
warnings.push(format!("{}: {why}; imported current membership (rules kept in archive)", album.name));
static_smart += 1;
}
None => {}
}
if !album.folder && !album.is_smart() {
album.photos = members;
}
ops.push(Op::AddAlbum { album });
}
}
}
let stacks = stack_ops(s, &data, &ids, &mut ops, &mut warnings);
if !ops.is_empty() {
s.commit("Import Lightroom Catalog", Op::Batch { ops })?;
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"smartAlbums":smart_albums,"smartAsStatic":static_smart,"smartKept":smart_kept,"stacks":stacks,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
/// Lightroom folder stacks → stacks, top (position 1) first. Photos already stacked in the library
/// keep their stack; a stack that would have fewer than two photos is skipped.
fn stack_ops(s: &mut crate::Session, data: &CatalogImport, ids: &HashMap<i64, PhotoId>, ops: &mut Vec<Op>, warnings: &mut Vec<String>) -> usize {
let mut groups: BTreeMap<i64, Vec<(f64, PhotoId, bool)>> = BTreeMap::new();
for r in &data.stacks {
let Some(photo) = ids.get(&number(r, "image")).copied() else { continue };
let position = r.get("position").and_then(Value::as_f64).filter(|p| p.is_finite()).unwrap_or(f64::MAX);
groups.entry(number(r, "stack")).or_default().push((position, photo, number(r, "collapsed") != 0));
}
let mut taken: HashSet<PhotoId> = HashSet::new();
let mut made = 0;
for (stack, mut members) in groups {
members.sort_by(|a, b| a.0.total_cmp(&b.0));
let collapsed = members.iter().any(|m| m.2);
let stacked = members.iter().any(|m| s.catalog.stack_of(m.1).is_some());
let photos: Vec<PhotoId> = members.into_iter().map(|m| m.1).filter(|p| s.catalog.stack_of(*p).is_none() && taken.insert(*p)).collect();
if stacked && photos.len() < 2 {
continue; // already stacked by an earlier import
}
if photos.len() < 2 {
warnings.push(format!("stack {stack}: fewer than two photos left to stack; skipped"));
continue;
}
let id = s.catalog.alloc_stack_id();
ops.push(Op::AddStack { stack: lightcraft_catalog::Stack { id, photos, collapsed } });
made += 1;
}
made
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_geom::CropGeometry;
use lightcraft_geom::{Affine, CropGeometry, Point, Rect};
fn row(v: Value) -> Row {
serde_json::from_value(v).unwrap()
}
@@ -935,6 +1025,7 @@ mod tests {
row(json!({"collection":10,"image":1,"positionInCollection":"b"})),
],
collection_content: Vec::new(),
stacks: Vec::new(),
warnings: Vec::new(),
}
}
@@ -966,6 +1057,39 @@ mod tests {
assert!(data.photos[1].settings.contains("CameraProfile")); // unmapped source stays intact
}
#[test]
fn smart_collections_become_smart_albums_and_stacks_are_kept() {
let mut s = crate::Session::new();
let mut data = sample();
data.collections.push(row(json!({"id_local":11,"name":"Best","creationId":"com.adobe.ag.library.smart_collection"})));
data.collections.push(row(json!({"id_local":12,"name":"Odd","creationId":"com.adobe.ag.library.smart_collection"})));
data.members.push(row(json!({"collection":12,"image":1})));
data.collection_content = vec![
row(json!({"collection":11,"owningModule":"ag.library.smart_collection",
"content":"s = { { criteria = \"rating\", operation = \">=\", value = 4 }, combine = \"intersect\" }"})),
row(json!({"collection":12,"owningModule":"ag.library.smart_collection",
"content":"s = { { criteria = \"collection\", operation = \"all\", value = \"x\" } }"})),
];
data.stacks =
vec![row(json!({"stack":5,"image":1,"position":2.0,"collapsed":1})), row(json!({"stack":5,"image":2,"position":1.0,"collapsed":1}))];
let r = apply(&mut s, data.clone(), false).unwrap();
assert_eq!(r["smartAlbums"], 1);
assert_eq!(r["smartAsStatic"], 1);
assert_eq!(r["stacks"], 1);
let best = s.catalog.albums().find(|a| a.name == "Best").unwrap();
assert!(best.is_smart());
let odd = s.catalog.albums().find(|a| a.name == "Odd").unwrap();
assert!(!odd.is_smart() && odd.photos.len() == 1);
let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap().id;
let stack = s.catalog.stacks().next().unwrap();
assert_eq!(stack.photos.first(), Some(&copy));
assert!(stack.collapsed);
// Reimport neither duplicates the stack nor warns about it.
let r = apply(&mut s, data, false).unwrap();
assert_eq!(r["stacks"], 0);
assert_eq!(s.catalog.stacks().count(), 1);
assert!(!r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("stack"))), "{}", r["warnings"]);
}
#[test]
fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() {
let mut s = crate::Session::new();
let data = sample();
@@ -1167,6 +1291,73 @@ mod tests {
assert_eq!(r, [0.1, 0.1, 0.8, 0.6]);
}
/// Lightroom's saved Upright isn't read from catalogs until a real catalog verifies how it is
/// stored there (issue #205): whatever form the fields take, the mode is solved again (no
/// transform is kept) and the report names what was lost. Upright Off loses nothing.
#[test]
fn catalog_upright_falls_back_to_solving_and_is_reported() {
let auto = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000";
for settings in [
format!(r#"s = {{ PerspectiveUpright = 1, UprightVersion = 151388160, UprightTransformCount = 6, UprightTransform_1 = "{auto}" }}"#),
"s = { PerspectiveUpright = 3, UprightVersion = 151388160, UprightTransform_3 = { 1, 0, 0, 0, 1, 0, 0, 0, 1 } }".to_string(),
r#"s = { PerspectiveUpright = 5, UprightFourSegmentsCount = 2, UprightFourSegments_0 = "0.2, 0.1, 0.25, 0.9" }"#.to_string(),
] {
for o in [Orientation::Normal, Orientation::Rotate90] {
let (partial, unknown) = mapped_settings(&settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
assert!(partial["geometry"]["upright_transform"].is_null(), "{settings}: {partial}");
assert_eq!(unknown.iter().filter(|u| u.starts_with("Upright")).count(), 1, "{settings}: {unknown:?}");
}
}
let off = format!(r#"s = {{ PerspectiveUpright = 0, UprightVersion = 151388160, UprightTransform_1 = "{auto}" }}"#);
let (_, unknown) = mapped_settings(&off, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
assert!(unknown.iter().all(|u| !u.starts_with("Upright")), "{unknown:?}");
}
/// The catalog's XMP column reports what it can't carry over too: Guided guides there used
/// to import as Guided without guides and no word; a saved Upright in both the XMP and the
/// develop settings is reported once.
#[test]
fn catalog_xmp_upright_and_guides_are_reported() {
let xmp = |fields: &str| {
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:Exposure2012="+0.30" {fields}/></rdf:RDF></x:xmpmeta>"#
)
};
let guides = xmp(
r#"crs:PerspectiveUpright="5" crs:UprightFourSegmentsCount="2" crs:UprightFourSegments_0="0.2, 0.1, 0.25, 0.9" crs:UprightFourSegments_1="0.8, 0.1, 0.75, 0.9""#,
);
let auto = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000";
let matrix = xmp(&format!(
r#"crs:PerspectiveUpright="1" crs:UprightVersion="151388160" crs:UprightTransformCount="6" crs:UprightTransform_1="{auto}""#
));
let lua =
format!(r#"s = {{ PerspectiveUpright = 1, UprightVersion = 151388160, UprightTransformCount = 6, UprightTransform_1 = "{auto}" }}"#);
for (packet, settings, mode) in
[(guides, String::new(), lightcraft_develop::Upright::Guided), (matrix, lua, lightcraft_develop::Upright::Auto)]
{
let mut data = sample();
data.photos.truncate(1);
data.photos[0].image.remove("masterImage");
data.photos[0].xmp = packet;
data.photos[0].settings = settings;
let mut s = crate::Session::new();
let r = apply(&mut s, data, false).unwrap();
let p = s.catalog.photos().next().unwrap();
assert_eq!((p.develop.geometry.upright, p.develop.geometry.upright_transform), (mode, None));
let notes: Vec<String> = r["unmapped"]
.as_object()
.into_iter()
.flat_map(|m| m.values())
.filter_map(Value::as_array)
.flatten()
.filter_map(|v| v.as_str())
.filter(|v| v.starts_with("Upright"))
.map(String::from)
.collect();
assert_eq!(notes.len(), 1, "{mode:?}: {r}");
}
}
#[test]
fn deferred_auto_tone_sentinels_never_become_real_slider_values() {
let mut data = sample();
@@ -1227,6 +1418,61 @@ mod tests {
}
}
fn smart_sample(content: &str) -> CatalogImport {
let mut data = sample();
data.collections.push(row(json!({"id_local":11,"name":"Best","creationId":"com.adobe.ag.library.smart_collection"})));
data.members.push(row(json!({"collection":11,"image":1})));
data.collection_content = vec![row(json!({"collection":11,"owningModule":"ag.library.smart_collection","content":content}))];
data
}
const SUPPORTED: &str = "s = { { criteria = \"rating\", operation = \">=\", value = 4 }, combine = \"intersect\" }";
const UNSUPPORTED: &str = "s = { { criteria = \"collection\", operation = \"all\", value = \"x\" } }";
#[test]
fn reimport_keeps_an_earlier_static_album_static_and_merges_its_membership() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport_keeps_an_earlie-{}", std::process::id()));
let mut s = crate::Session::new();
s.open_library(&dir, false).unwrap();
let r = apply(&mut s, smart_sample(UNSUPPORTED), false).unwrap();
assert_eq!((r["smartAlbums"].as_u64(), r["smartAsStatic"].as_u64()), (Some(0), Some(1)));
let albums = s.catalog.albums().count();
let mut data = smart_sample(SUPPORTED);
data.members.push(row(json!({"collection":11,"image":2})));
let r = apply(&mut s, data.clone(), false).unwrap();
assert_eq!((r["smartAlbums"].as_u64(), r["smartAsStatic"].as_u64(), r["smartKept"].as_u64()), (Some(0), Some(1), Some(0)));
assert!(
r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("Best:") && w.contains("stays static"))),
"{}",
r["warnings"]
);
let best = s.catalog.albums().find(|a| a.name == "Best").unwrap();
assert!(!best.is_smart());
assert_eq!(best.photos.len(), 2, "new membership merged");
// Repeating the import creates no duplicates.
apply(&mut s, data, false).unwrap();
assert_eq!(s.catalog.albums().count(), albums);
assert_eq!(s.catalog.albums().find(|a| a.name == "Best").unwrap().photos.len(), 2);
s.close_library().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn reimport_preserves_user_edited_smart_rules() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport_preserves_user_-{}", std::process::id()));
let mut s = crate::Session::new();
s.open_library(&dir, false).unwrap();
let r = apply(&mut s, smart_sample(SUPPORTED), false).unwrap();
assert_eq!(r["smartAlbums"], 1);
let albums = s.catalog.albums().count();
let id = s.catalog.albums().find(|a| a.name == "Best").unwrap().id;
let edited = lightcraft_catalog::Filter { rating: 2, ..Default::default() };
s.commit("Edit rules", Op::SetAlbumRules { id, rules: Box::new(edited.clone()) }).unwrap();
let r = apply(&mut s, smart_sample(SUPPORTED), false).unwrap();
assert_eq!((r["smartAlbums"].as_u64(), r["smartAsStatic"].as_u64(), r["smartKept"].as_u64()), (Some(1), Some(0), Some(1)));
let best = s.catalog.album(id).unwrap();
assert_eq!(best.smart.as_deref().map(|f| f.rating), Some(2));
assert_eq!(s.catalog.albums().count(), albums);
s.close_library().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id()));
@@ -1299,6 +1545,10 @@ mod tests {
}
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
native_catalog_with_last_table_page(tables, None)
}
fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec<SqlValue>)], last_page: Option<i64>) -> Vec<u8> {
use SqlValue::{Integer as I, Text as T};
let mut bytes = vec![0u8; 5 * 2048];
bytes[..16].copy_from_slice(b"SQLite format 3\0");
@@ -1317,7 +1567,7 @@ mod tests {
T("table".into()),
T((*name).into()),
T((*name).into()),
I(i as i64 + 2),
I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }),
T(format!("CREATE TABLE {name} ({columns})")),
]);
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
@@ -1354,6 +1604,29 @@ mod tests {
assert_eq!(catalog.photos[0].uuid, "fixture-image");
assert_eq!(number(&catalog.photos[0].image, "rating"), 5);
assert!(catalog.photos[0].path.ends_with("test.ARW"));
// Put the required image table beyond the old 1 GiB cutoff without allocating it.
// A main-file-sized allocation would defeat the purpose of the page-backed reader.
// The large fixture relies on Unix sparse-file behavior; small parity runs everywhere.
#[cfg(unix)]
{
let far_offset = 1u64 << 30;
let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64));
use std::io::{Seek, SeekFrom, Write};
let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap();
sparse.write_all(&bytes).unwrap();
sparse.set_len(far_offset + 2048).unwrap();
sparse.seek(SeekFrom::Start(far_offset)).unwrap();
sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap();
drop(sparse);
let large = read(&path).unwrap();
assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap());
let mut session = crate::Session::new();
session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(session.catalog.len(), 1);
assert_eq!(session.catalog.photos().next().unwrap().rating, 5);
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.catalog.is_empty());
}
std::fs::write(&path, b"bad catalog").unwrap();
assert!(read(&path).is_err());
std::fs::remove_file(path).unwrap();
+2
View File
@@ -332,6 +332,7 @@ mod tests {
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
stacks: Vec::new(),
warnings: Vec::new(),
}
}
@@ -375,6 +376,7 @@ mod tests {
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
stacks: Vec::new(),
warnings: Vec::new(),
},
normal: Prepared::default(),
+232
View File
@@ -0,0 +1,232 @@
//! Lightroom Classic smart-collection rules → smart-album rules, where they can be expressed.
//!
//! A smart collection's rules are a Lua table in `AgLibraryCollectionContent.content`
//! (`owningModule = "ag.library.smart_collection"`): positional criteria tables
//! `{ criteria = "rating", operation = ">=", value = 3 }` (or nested groups with their own
//! `combine`) and a `combine` of `"intersect"` (all), `"union"` (any) or `"exclude"` (none).
//! Anything we can't express exactly returns `Err` with the reason; the caller then keeps the
//! collection's current membership as a regular album and archives the original rules.
//!
//! Sources: own design, from the structure of the Lua literal as described in public prose
//! write-ups of the catalog format; no Adobe code or data.
use lightcraft_catalog::{Match, Rule, RuleSet};
use serde_json::{Value, json};
use crate::preset_import::{Lua, parse_lua};
const MAX_DEPTH: usize = 16;
/// The rule set a smart collection's `content` describes, or why it can't be expressed.
pub(crate) fn smart_rules(content: &str) -> Result<RuleSet, String> {
let root = parse_lua(content)?;
group(&root, 0)
}
fn group(t: &Lua, depth: usize) -> Result<RuleSet, String> {
if depth > MAX_DEPTH {
return Err("smart rules nest too deeply".into());
}
let Lua::Table(items, _) = t else { return Err("smart rules are not a table".into()) };
let mode = match t.get("combine").and_then(Lua::str).unwrap_or("intersect") {
"intersect" => Match::All,
"union" => Match::Any,
"exclude" => Match::None,
other => return Err(format!("unknown combine `{other}`")),
};
let mut rules = Vec::new();
for item in items {
if item.get("criteria").is_some() {
rules.push(criterion(item)?);
} else {
rules.push(Rule::Group { group: group(item, depth + 1)? });
}
}
if rules.is_empty() {
return Err("smart collection has no rules".into());
}
Ok(RuleSet { mode, rules })
}
fn num(v: Option<&Lua>) -> Option<f64> {
match v {
Some(Lua::Num(n)) if n.is_finite() => Some(*n),
Some(Lua::Str(s)) => s.trim().parse().ok().filter(|n: &f64| n.is_finite()),
_ => None,
}
}
fn field(field: &str, op: &str, value: Value) -> Rule {
Rule::Field { field: field.into(), op: op.into(), value }
}
fn number_op(op: &str) -> Option<&'static str> {
Some(match op {
"==" => "is",
"!=" => "isNot",
">=" => "gte",
"<=" => "lte",
">" => "gt",
"<" => "lt",
_ => return None,
})
}
fn criterion(c: &Lua) -> Result<Rule, String> {
let name = c.get("criteria").and_then(Lua::str).unwrap_or_default();
let op = c.get("operation").and_then(Lua::str).unwrap_or_default();
let value = c.get("value");
let unsupported = || format!("rule `{name} {op}` has no equivalent");
match name {
"rating" | "isoSpeedRating" | "focalLength" | "aperture" => {
let target = match name {
"rating" => "rating",
"isoSpeedRating" => "iso",
"focalLength" => "focalLength",
_ => "aperture",
};
let n = num(value).ok_or_else(unsupported)?;
if op == "in" {
let hi = num(c.get("value2")).ok_or_else(unsupported)?;
return Ok(field(target, "between", json!([n, hi])));
}
let op = number_op(op).ok_or_else(unsupported)?;
Ok(field(target, op, json!(n)))
}
"pick" => {
let flag = match num(value).map(|n| n as i64) {
Some(1) => "pick",
Some(-1) => "reject",
Some(0) => "none",
_ => return Err(unsupported()),
};
match op {
"==" => Ok(field("flag", "is", json!(flag))),
"!=" => Ok(field("flag", "isNot", json!(flag))),
_ => Err(unsupported()),
}
}
"labelColor" => {
let label = value.and_then(Lua::str).map(str::to_ascii_lowercase).ok_or_else(unsupported)?;
if !["red", "yellow", "green", "blue", "purple", "none"].contains(&label.as_str()) {
return Err(unsupported());
}
match op {
"==" => Ok(field("label", "is", json!(label))),
"!=" => Ok(field("label", "isNot", json!(label))),
_ => Err(unsupported()),
}
}
"hasAdjustments" => {
let yes = match value {
Some(Lua::Bool(b)) => *b,
Some(Lua::Num(n)) => *n != 0.0,
None => true,
_ => return Err(unsupported()),
};
match op {
"==" | "" => Ok(field("edited", "is", json!(yes))),
"!=" => Ok(field("edited", "is", json!(!yes))),
_ => Err(unsupported()),
}
}
_ => {
let target = match name {
"keywords" => "keywords",
"filename" => "fileName",
"camera" => "camera",
"lens" => "lens",
"title" => "title",
"caption" => "caption",
"copyname" | "copyName" => "copyName",
"creator" => "creator",
"copyright" => "copyright",
"city" => "city",
"country" => "country",
"location" => "location",
"state" => "state",
"fileFormat" => "format",
_ => return Err(unsupported()),
};
text_rule(target, op, value.and_then(Lua::str).unwrap_or_default()).ok_or_else(unsupported)
}
}
}
fn text_rule(target: &str, op: &str, value: &str) -> Option<Rule> {
let words: Vec<&str> = value.split_whitespace().collect();
let keywords = target == "keywords";
let rule = match op {
"empty" => field(target, "isEmpty", Value::Null),
"notEmpty" => field(target, "isNotEmpty", Value::Null),
_ if words.is_empty() => return None,
// Every word must appear: what "contains" already means here.
"all" | "words" => field(target, "contains", json!(value)),
"any" if words.len() == 1 => field(target, "contains", json!(value)),
"any" => Rule::Group { group: RuleSet { mode: Match::Any, rules: words.iter().map(|w| field(target, "contains", json!(w))).collect() } },
"noneOf" => {
Rule::Group { group: RuleSet { mode: Match::All, rules: words.iter().map(|w| field(target, "notContains", json!(w))).collect() } }
}
"beginsWith" => field(target, "startsWith", json!(value)),
"endsWith" if !keywords => field(target, "endsWith", json!(value)),
"==" => field(target, "is", json!(value)),
"!=" if !keywords => field(target, "isNot", json!(value)),
_ => return None,
};
Some(rule)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_common_rules_and_groups() {
let set = smart_rules(
r#"s = { { criteria = "rating", operation = ">=", value = 3, value2 = 0 },
{ criteria = "pick", operation = "==", value = 1 },
{ criteria = "keywords", operation = "any", value = "italy france" },
{ { criteria = "labelColor", operation = "==", value = "red" },
{ criteria = "isoSpeedRating", operation = "in", value = 100, value2 = 800 },
combine = "union" },
combine = "intersect" }"#,
)
.unwrap();
assert_eq!(set.mode, Match::All);
assert_eq!(set.rules.len(), 4);
assert_eq!(set.rules[0], field("rating", "gte", json!(3.0)));
assert_eq!(set.rules[1], field("flag", "is", json!("pick")));
let Rule::Group { group } = &set.rules[2] else { panic!() };
assert_eq!(group.mode, Match::Any);
let Rule::Group { group } = &set.rules[3] else { panic!() };
assert_eq!(group.rules[1], field("iso", "between", json!([100.0, 800.0])));
let cat = lightcraft_catalog::Catalog::default();
let mut tmp = cat;
let id = tmp.alloc_album_id();
let mut album = lightcraft_catalog::Album::new(id, "s");
album.smart = Some(Box::new(lightcraft_catalog::Filter { rule_set: Some(set), ..Default::default() }));
assert!(tmp.album_filter_problem(album.smart.as_deref().unwrap(), None).is_none());
}
#[test]
fn unexpressible_rules_are_reported_not_guessed() {
for bad in [
r#"s = { { criteria = "collection", operation = "all", value = "x" }, combine = "intersect" }"#,
r#"s = { { criteria = "rating", operation = "~", value = 3 } }"#,
r#"s = { combine = "intersect" }"#,
r#"s = { { criteria = "rating", operation = ">=", value = "abc" } }"#,
"not lua at all {",
] {
assert!(smart_rules(bad).is_err(), "{bad}");
}
}
#[test]
fn deep_nesting_is_bounded() {
let mut s = String::from("{ criteria = \"rating\", operation = \">=\", value = 1 }");
for _ in 0..40 {
s = format!("{{ {s} }}");
}
assert!(smart_rules(&s).is_err());
}
}
+212 -8
View File
@@ -6,7 +6,79 @@
//! than a SQL engine. It never writes its input and has no SQLite runtime
//! dependency.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::fs::{File, Metadata};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const MAX_DATABASE_BYTES: u64 = 16 << 30;
/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file.
pub struct FileSource {
file: Mutex<File>,
path: PathBuf,
before: Metadata,
header: Vec<u8>,
}
impl FileSource {
pub fn open(path: &Path) -> Result<Self, String> {
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
return Err("SQLite catalog must be a regular file".into());
}
let mut file = File::open(path).map_err(|e| e.to_string())?;
let before = file.metadata().map_err(|e| e.to_string())?;
if !before.is_file() {
return Err("SQLite catalog must be a regular file".into());
}
if before.len() > MAX_DATABASE_BYTES {
return Err("catalog exceeds 16 GiB import limit".into());
}
let mut header = vec![0; 100];
file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?;
let source = Self { file: Mutex::new(file), path: path.into(), before, header };
source.ensure_unchanged()?;
Ok(source)
}
pub fn header(&self) -> &[u8] {
&self.header
}
pub fn ensure_unchanged(&self) -> Result<(), String> {
let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
let handle = file.metadata().map_err(|e| e.to_string())?;
let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?;
if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) {
return Err("catalog changed during reading; close Lightroom and retry".into());
}
Ok(())
}
fn page(&self, page_id: u32, size: usize) -> Result<Vec<u8>, String> {
let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?;
if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) {
return Err(format!("page {page_id} is truncated"));
}
let mut out = vec![0; size];
let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?;
Ok(out)
}
}
fn same_metadata(a: &Metadata, b: &Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if a.dev() != b.dev() || a.ino() != b.ino() {
return false;
}
}
a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok()
}
const MAX_PAGE_ID: u32 = 16_777_216;
const MAX_COLUMNS: usize = 16_384;
@@ -44,6 +116,7 @@ pub struct LiveTable {
/// Read-only SQLite database image, with a committed WAL overlay if supplied.
pub struct Database {
main: Vec<u8>,
file: Option<FileSource>,
overlay: HashMap<u32, Vec<u8>>,
page_size: usize,
reserved: usize,
@@ -84,17 +157,34 @@ impl Database {
/// The WAL argument must include its 32-byte header and only complete
/// frames through a commit. Frames are overlaid by page number, with the
/// final commit's database-size field defining the visible page count.
#[cfg(test)]
pub fn open_with_wal(main: Vec<u8>, committed_wal: &[u8]) -> Result<Self, String> {
Self::open(main, None, committed_wal)
}
pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result<Self, String> {
Self::open(source.header().to_vec(), Some(source), committed_wal)
}
pub fn ensure_unchanged(&self) -> Result<(), String> {
if let Some(file) = &self.file {
file.ensure_unchanged()?;
}
Ok(())
}
fn open(main: Vec<u8>, file: Option<FileSource>, committed_wal: &[u8]) -> Result<Self, String> {
let main_header = main.get(..100).ok_or("truncated SQLite header")?;
if main_header.get(..16) != Some(b"SQLite format 3\0") {
return Err("invalid SQLite header signature".into());
}
let page_size = sqlite_page_size(be_u16(main_header, 16)?)?;
validate_header_fields(main_header, page_size)?;
if main.len() < page_size || !main.len().is_multiple_of(page_size) {
let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len());
if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) {
return Err("SQLite image is not a whole number of pages".into());
}
let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?;
let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?;
if main_pages == 0 || main_pages > MAX_PAGE_ID {
return Err("invalid SQLite image page count".into());
}
@@ -140,7 +230,7 @@ impl Database {
_ => return Err("unsupported SQLite text encoding".into()),
};
let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
let schema = db.parse_schema()?;
db.tables = schema.tables;
db.rowid_aliases = schema.aliases;
@@ -166,17 +256,20 @@ impl Database {
Ok(state.out)
}
fn page(&self, page_id: u32) -> Result<&[u8], String> {
fn page(&self, page_id: u32) -> Result<Cow<'_, [u8]>, String> {
if page_id == 0 || page_id > self.page_count {
return Err(format!("page {page_id} is outside database"));
}
if let Some(page) = self.overlay.get(&page_id) {
return Ok(page);
return Ok(Cow::Borrowed(page));
}
if let Some(file) = &self.file {
return file.page(page_id, self.page_size).map(Cow::Owned);
}
let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?;
let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?;
let end = start.checked_add(self.page_size).ok_or("page offset overflow")?;
self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated"))
self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated"))
}
fn visit_table(&self, page_id: u32, ncols: usize, alias: Option<usize>, depth: usize, state: &mut WalkState) -> Result<(), String> {
@@ -219,7 +312,7 @@ impl Database {
if state.payload_total > MAX_TABLE_PAYLOAD {
return Err("SQLite table payload exceeds bounded import size".into());
}
let payload = self.payload(page, at, payload_len)?;
let payload = self.payload(&page, at, payload_len)?;
let mut values = decode_record(&payload, ncols, self.encoding)?;
if let Some(index) = alias {
if index >= values.len() {
@@ -325,7 +418,7 @@ impl Database {
out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?);
remaining -= take;
if remaining > 0 {
next = be_u32_at(overflow, 0)?;
next = be_u32_at(&overflow, 0)?;
}
}
Ok(out)
@@ -871,6 +964,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result<u32, String> {
mod tests {
use super::*;
struct Scratch(PathBuf);
impl Scratch {
fn new(bytes: &[u8]) -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id()));
std::fs::write(&path, bytes).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn file_backed(mut db: Database) -> (Scratch, Database) {
let scratch = Scratch::new(&db.main);
db.file = Some(FileSource::open(&scratch.0).unwrap());
db.main.truncate(100);
(scratch, db)
}
#[test]
fn file_source_bounds_and_mutation_detection() {
let scratch = Scratch::new(&[0; 512]);
let source = FileSource::open(&scratch.0).unwrap();
assert_eq!(source.page(1, 512).unwrap().len(), 512);
assert!(source.page(2, 512).unwrap_err().contains("truncated"));
File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap();
assert!(source.page(1, 512).is_err());
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
drop(source);
// Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows.
#[cfg(unix)]
{
File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap();
assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit");
}
}
#[cfg(unix)]
#[test]
fn file_source_detects_same_sized_path_replacement() {
let original = Scratch::new(&[0; 512]);
let replacement = Scratch::new(&[0; 512]);
let source = FileSource::open(&original.0).unwrap();
std::fs::rename(&replacement.0, &original.0).unwrap();
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
}
#[test]
fn file_source_detects_same_length_content_changes() {
let scratch = Scratch::new(&[0; 512]);
let source = FileSource::open(&scratch.0).unwrap();
std::fs::write(&scratch.0, [1; 512]).unwrap();
// Make the timestamp change deterministic even on filesystems with coarse timestamps.
File::options()
.write(true)
.open(&scratch.0)
.unwrap()
.set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2))
.unwrap();
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
}
fn varint(mut value: u64) -> Vec<u8> {
let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)];
value >>= 7;
@@ -996,6 +1155,40 @@ mod tests {
assert!(tables.iter().any(|table| table.name == "t"));
let rows = db.read_table(2, 2).unwrap();
assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1)));
let scratch = Scratch::new(&db.main);
let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap();
assert_eq!(file_db.live_tables().unwrap(), tables);
assert_eq!(file_db.read_table(2, 2).unwrap(), rows);
assert_eq!(file_db.main.len(), 100);
file_db.ensure_unchanged().unwrap();
// The same committed WAL page overrides the lazily read main page.
let mut wal = vec![0; 32 + 24 + 512];
wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes());
wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
wal[8..12].copy_from_slice(&512u32.to_be_bytes());
wal[32..36].copy_from_slice(&2u32.to_be_bytes());
wal[36..40].copy_from_slice(&2u32.to_be_bytes());
leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']);
let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap();
let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap();
assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap());
assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2));
let mut malformed = db.main.clone();
malformed.push(0);
let partial = Scratch::new(&malformed);
assert_eq!(
Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(),
"SQLite image is not a whole number of pages"
);
let mut malformed = db.main.clone();
malformed[24..28].copy_from_slice(&1u32.to_be_bytes());
malformed[92..96].copy_from_slice(&1u32.to_be_bytes());
malformed[28..32].copy_from_slice(&3u32.to_be_bytes());
let oversized = Scratch::new(&malformed);
assert_eq!(
Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(),
"SQLite header page count exceeds image"
);
}
#[test]
@@ -1012,6 +1205,7 @@ mod tests {
leaf(&mut main[1024..1536], 0, 1, &[2, 0]);
leaf(&mut main[1536..], 0, 2, &[2, 0]);
let db = Database {
file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1043,6 +1237,7 @@ mod tests {
let mut aliases = HashMap::new();
aliases.insert(1, 0);
let db = Database {
file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1063,6 +1258,7 @@ mod tests {
main[103..105].copy_from_slice(&1u16.to_be_bytes());
main[108..110].copy_from_slice(&490u16.to_be_bytes());
let db = Database {
file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1073,6 +1269,8 @@ mod tests {
rowid_aliases: HashMap::new(),
};
assert!(db.read_table(1, 1).is_err());
let (_scratch, db) = file_backed(db);
assert!(db.read_table(1, 1).is_err());
}
#[test]
@@ -1085,6 +1283,7 @@ mod tests {
let mut main = vec![0u8; 2 * 512];
let db = Database {
file: None,
main: std::mem::take(&mut main),
overlay: HashMap::new(),
page_size: 512,
@@ -1111,6 +1310,10 @@ mod tests {
assert_eq!(bytes.len(), 600);
assert_eq!(&bytes[..92], &[b'x'; 92]);
assert_eq!(&bytes[92..], &[b'y'; 508]);
let (_scratch, mut db) = file_backed(db);
db.overlay.clear();
let bytes = db.payload(&cell_page, 0, 600).unwrap();
assert_eq!(&bytes[92..], &[b'y'; 508]);
}
#[test]
@@ -1134,6 +1337,7 @@ mod tests {
let mut page = vec![0u8; 512];
page[16..18].copy_from_slice(&512u16.to_be_bytes());
let db = Database {
file: None,
main: vec![0; 512],
overlay: HashMap::new(),
page_size: 512,
+68 -13
View File
@@ -22,7 +22,7 @@ use std::sync::{Arc, Weak};
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_preview::{DiskCache, Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 62;
pub const RENDER_CACHE_VERSION: u64 = 63;
/// 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];
@@ -431,19 +431,22 @@ fn rendered_budget(share: usize) -> usize {
impl MediaCache {
/// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired
/// (its files stay): render jobs still holding it can't write through it any more, even
/// into a directory a later clear emptied (`dir` may be the same one).
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
/// into a directory a later clear emptied (`dir` may be the same one). A `chosen` folder
/// (the user's, not the library's own) is never created: while it is missing, thumbnails
/// are kept in memory only.
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64, chosen: bool) {
self.rendered.retire();
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
let disk = if chosen { DiskCache::chosen(dir, disk_bytes) } else { DiskCache::new(dir, disk_bytes) };
self.rendered = Arc::new(PreviewCache::with_disk_cache(rendered_budget(self.budget), disk));
}
/// Change the disk budget of the cache in `dir`: in place when that cache is attached (its
/// thumbnails are keyed by content, so they and the textures shown from them stay valid,
/// and the same cache object keeps its generation), else attach it.
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) {
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64, chosen: bool) {
match self.rendered.disk().filter(|d| d.dir() == dir) {
Some(d) => d.set_budget(disk_bytes),
None => self.attach_disk_cache(dir, disk_bytes),
None => self.attach_disk_cache(dir, disk_bytes, chosen),
}
}
@@ -550,9 +553,8 @@ impl MediaCache {
}
}
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
/// (with a smart preview's stored camera tone curve).
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
/// Prefer a cached source with decoder facts, preserving thumbnail/preview/full priority.
fn cached_source(&self, id: PhotoId) -> Option<&DecodedSource> {
let cached = || {
self.thumbs
.peek(&id)
@@ -560,7 +562,12 @@ impl MediaCache {
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
};
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
cached().find(|s| s.info.is_some()).or_else(|| cached().next())
}
/// The decoder's cached facts, else `header` (with a smart preview's stored camera tone curve).
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
match self.cached_source(id) {
Some(s) => s.info_or(header),
None => header,
}
@@ -903,11 +910,24 @@ impl RenderJob {
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
// the GPU when there is one.
let gpu = self.cache.is_none();
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
// a clipping preview tests sRGB values, like the histogram: rendered in sRGB, then
// converted for the display below
let clip_view = self.request.overlay.is_clipping();
let request = if clip_view { RenderRequest { display: None, ..self.request } } else { self.request };
let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &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) {
// a diagnostic view: the histogram stays the photo's (taken before the overlay
// was drawn), and nothing goes to the view cache
(Some(d), Some(_)) if self.request.overlay != lightcraft_pipeline::Overlay::None => {
let converted = if clip_view { d.profile.from_srgb(&mut rendered.image) } else { d.profile.correct(&mut rendered.image) };
if let Err(e) = converted {
log::warn!("display profile: {e}");
}
Some(d.id())
}
(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);
@@ -1463,7 +1483,7 @@ impl crate::Session {
/// says and what a smart preview renders with. Only for raws edited in Kelvin.
fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) {
let Some(p) = self.catalog.photo(id) else { return };
if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) {
if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() {
return;
}
let read = (info.as_shot_temp, info.as_shot_tint);
@@ -1529,6 +1549,11 @@ impl crate::Session {
self.media.source_facts(id, header)
}
/// Borrow source mattes for selection notices without constructing render facts each frame.
pub fn source_mattes(&self, id: PhotoId) -> Option<&lightcraft_pipeline::masks::Mattes> {
self.media.cached_source(id).and_then(|s| s.info.as_ref()).and_then(|info| info.mattes.as_deref())
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
@@ -1568,6 +1593,18 @@ pub struct ProbeInfo {
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
pub preview_only: Option<String>,
/// How the stored image is turned into the oriented one `width × height` describes (the
/// file's EXIF orientation, as the decoder applies it); `None` when not known. Lightroom's
/// saved Upright matrices are in the stored frame ([`crate::crs::SavedUpright`]).
pub orientation: Option<lightcraft_geom::Orientation>,
}
impl ProbeInfo {
/// The stored image's frame: its orientation and its size before that orientation, when both
/// are known (see [`crate::sidecar::SidecarData::set_frame`]).
pub fn stored_frame(&self) -> Option<crate::sidecar::StoredFrame> {
crate::sidecar::StoredFrame::of_shown(self.orientation?, f64::from(self.width), f64::from(self.height))
}
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
@@ -1693,6 +1730,24 @@ mod thumbnail_hash_tests {
#[cfg(test)]
mod tests {
#[test]
fn selection_notices_borrow_the_same_mattes_as_render_facts() {
let mut session = crate::Session::with_demo();
let id = session.active().unwrap();
assert!(session.source_mattes(id).is_none());
let mattes = std::sync::Arc::new(lightcraft_pipeline::masks::Mattes::default());
session.media.insert_source(
id,
super::SourceLevel::Thumb,
super::DecodedSource::new(
std::sync::Arc::new(lightcraft_raster::Rgb32f::new(8, 8)),
Some(lightcraft_pipeline::SourceInfo { mattes: Some(mattes.clone()), ..Default::default() }),
),
);
assert!(std::ptr::eq(session.source_mattes(id).unwrap(), mattes.as_ref()));
assert!(std::sync::Arc::ptr_eq(session.source_info(id).mattes.as_ref().unwrap(), &mattes));
}
use super::*;
#[test]
+81 -3
View File
@@ -23,6 +23,73 @@ pub struct PresetFile {
pub presets: Vec<Preset>,
}
/// The session's presets (built-in, then the user's) with a generation that changes on every
/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
///
/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
/// Generations are unique across all lists in the process.
#[derive(Clone, Debug)]
pub struct PresetList {
items: Vec<Preset>,
generation: u64,
}
static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_generation() -> u64 {
PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl PresetList {
pub fn new(items: Vec<Preset>) -> PresetList {
PresetList { items, generation: next_generation() }
}
/// Changes whenever the list may have changed.
pub fn generation(&self) -> u64 {
self.generation
}
}
impl Default for PresetList {
fn default() -> PresetList {
PresetList::new(Vec::new())
}
}
impl std::ops::Deref for PresetList {
type Target = Vec<Preset>;
fn deref(&self) -> &Vec<Preset> {
&self.items
}
}
impl std::ops::DerefMut for PresetList {
fn deref_mut(&mut self) -> &mut Vec<Preset> {
self.generation = next_generation();
&mut self.items
}
}
impl<'a> IntoIterator for &'a PresetList {
type Item = &'a Preset;
type IntoIter = std::slice::Iter<'a, Preset>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter()
}
}
impl<'a> IntoIterator for &'a mut PresetList {
type Item = &'a mut Preset;
type IntoIter = std::slice::IterMut<'a, Preset>;
fn into_iter(self) -> Self::IntoIter {
self.generation = next_generation();
self.items.iter_mut()
}
}
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
pub fn slug(s: &str) -> String {
let mut out = String::new();
@@ -107,22 +174,33 @@ impl Session {
/// Add imported presets. Identical presets (same name, group and settings) already present are
/// skipped; id clashes get a fresh id. Returns the ids added.
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
use std::collections::{HashMap, HashSet};
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
let mut by_name: HashMap<(String, String), Vec<usize>> = HashMap::new();
for (i, q) in self.presets.iter().enumerate() {
by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
}
let mut added = Vec::new();
for mut p in presets {
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
let key = (p.name.clone(), p.group.clone());
let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
if same {
continue;
}
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
let mut id = base.clone();
let mut n = 2;
while self.presets.iter().any(|q| q.id == id) {
while ids.contains(&id) {
id = format!("{base}-{n}");
n += 1;
}
p.id = id;
}
p.builtin = false;
ids.insert(p.id.clone());
by_name.entry(key).or_default().push(self.presets.len());
added.push(p.id.clone());
self.presets.push(p);
}
+145
View File
@@ -0,0 +1,145 @@
//! Render to DNG (Lightroom Classic 15.5), split so the desktop app can run the heavy part off the
//! UI thread: [`prepare`] (on the session: checks the params and sets each photo's render up),
//! [`RenderDngJob::run`] (no session: renders, encodes and writes each DNG next to its original),
//! [`finish`] (on the session: adds each DNG to the library stacked on top of its original and
//! selects them). `photo.renderToDng` runs all three in a row; the desktop app hands `run` to a
//! worker with a row in the activity stack.
use std::path::Path;
use lightcraft_catalog::{PhotoId, Source};
use serde_json::{Value, json};
use crate::export::{Conflict, Destination, ExportFormat, ExportOptions, PreparedExport, run_batch};
use crate::{EngineError, Result, Session};
pub const COMMAND: &str = "photo.renderToDng";
/// Most photos one Render to DNG takes (each becomes a full-size float DNG and a library photo).
pub const MAX_PHOTOS: usize = 500;
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: COMMAND.into(), msg: msg.into() }
}
/// The renders of one Render to DNG, set up from the session ([`prepare`]).
pub struct RenderDngJob {
/// Per photo: its id, the folder its DNG goes to, its prepared render.
items: Vec<(PhotoId, String, PreparedExport)>,
opts: ExportOptions,
}
/// What [`RenderDngJob::run`] did with one photo: the DNG's path, or why there is none.
#[derive(Clone, Debug)]
pub struct Rendered {
pub original: PhotoId,
pub result: std::result::Result<String, String>,
}
/// Check `p` (`ids?`, `colorSpace?`, `dir?`) and set up each target photo's render.
pub fn prepare(s: &mut Session, p: &Value) -> Result<RenderDngJob> {
let mut ids = s.targets(p);
ids.dedup();
if ids.is_empty() {
return Err(bad("no active photo"));
}
if ids.len() > MAX_PHOTOS {
return Err(bad(format!("{} photos selected: render at most {MAX_PHOTOS} at a time", ids.len())));
}
use lightcraft_pipeline::OutputSpace as S;
let space = match p.get("colorSpace").and_then(Value::as_str).unwrap_or("proPhoto") {
"srgb" => S::Srgb,
"displayP3" => S::DisplayP3,
"adobeRgb" => S::AdobeRgb,
"rec2020" => S::Rec2020,
"prophoto" | "proPhoto" => S::ProPhoto,
c => return Err(bad(format!("colorSpace {c:?}: use proPhoto, rec2020, adobeRgb, displayP3 or srgb"))),
};
let opts = ExportOptions {
format: ExportFormat::RenderedDng,
color_space: space,
naming: "{name}-Rendered".into(),
conflict: Conflict::Unique,
..Default::default()
};
let mut items = Vec::with_capacity(ids.len());
for id in ids {
let ph = s.catalog.photo(id).ok_or_else(|| bad(format!("no photo {}", id.0)))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match p.get("dir").and_then(Value::as_str) {
Some(d) => d.to_string(),
None => return Err(bad("a generated demo photo has no folder: give `dir`")),
},
};
let item = crate::export::prepare_export(s, id, &opts, 1).map_err(bad)?;
items.push((id, dir, item));
}
Ok(RenderDngJob { items, opts })
}
impl RenderDngJob {
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Render and write each DNG (never over an existing file). `progress(done)` is asked before
/// each photo; `false` stops there (cancelled). Returns the outcomes and whether it was cancelled.
pub fn run(
self,
write: &mut dyn FnMut(&str, &[u8]) -> std::result::Result<(), String>,
progress: &mut dyn FnMut(usize) -> bool,
) -> (Vec<Rendered>, bool) {
let mut out = Vec::with_capacity(self.items.len());
for (i, (original, dir, item)) in self.items.into_iter().enumerate() {
if !progress(i) {
return (out, true);
}
let to = Destination { dir, exact: None };
let files = run_batch(vec![item], &self.opts, &to, write, &|path| Path::new(path).exists(), true, &mut |_, _| true);
let result = files.and_then(|files| {
let f = files.first().ok_or("nothing was written")?;
if let Some(e) = f.get("error").and_then(Value::as_str) {
return Err(e.to_string());
}
f.get("path")
.and_then(Value::as_str)
.filter(|p| p.ends_with(".dng"))
.map(str::to_string)
.ok_or_else(|| "nothing was written".to_string())
});
out.push(Rendered { original, result });
}
(out, false)
}
}
/// Add each written DNG to the library stacked (expanded) on top of its original and select them →
/// `{copies: [{path, id, original}], failed: [{id, error}], cancelled}`. A single photo that failed
/// is an error.
pub fn finish(s: &mut Session, done: Vec<Rendered>, cancelled: bool) -> Result<Value> {
let single = done.len() == 1 && !cancelled;
let mut copies = Vec::new();
let mut failed = Vec::new();
for r in done {
let added = r.result.and_then(|path| {
let imported = s.execute("library.import", &json!({"paths": [path]})).map_err(|e| e.to_string())?;
let new = imported["imported"].get(0).and_then(Value::as_u64).ok_or("the DNG could not be added")?;
let _ = s.execute("stack.group", &json!({"ids": [new, r.original.0], "top": new, "collapsed": false}));
Ok(json!({"path": path, "id": new, "original": r.original.0}))
});
match added {
Ok(c) => copies.push(c),
Err(e) if single => return Err(bad(e)),
Err(e) => failed.push(json!({"id": r.original.0, "error": e})),
}
}
let new: Vec<PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(PhotoId).collect();
if !new.is_empty() {
s.selection = crate::Selection { active: new.first().copied(), ids: new };
}
Ok(json!({"copies": copies, "failed": failed, "cancelled": cancelled}))
}
+90 -2
View File
@@ -363,8 +363,14 @@ impl Session {
let mut d = (*p.develop).clone();
d.masks.clear();
let content = crate::media::content_key(p);
// Reloaded lens data and camera-profile changes also change the model's rendered input.
let lens_key = serde_json::to_string(&p.embedded_lens).unwrap_or_default();
let mut h = mix(0xcbf2_9ce4_8422_2325, d.hash64());
h = mix(h, id.0);
h = mix(h, crate::camera_profiles::cache_key());
for b in lens_key.bytes() {
h = mix(h, u64::from(b));
}
for b in content.bytes() {
h = mix(h, u64::from(b));
}
@@ -411,7 +417,7 @@ impl Session {
job
}
};
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, render: Some(render), missing, kind, tag, reply })
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, source_key: key, render: Some(render), missing, kind, tag, reply })
}
/// Send a request and wait for its result (CLI, MCP, tests; or when not in background mode).
@@ -560,7 +566,8 @@ impl Session {
self.segment_queue(id, kind, tag, Some(region))?;
} else {
let patch = self.segment_wait(id, kind, Some(region))?;
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
let source_key = self.segment_key(id).ok_or("no such photo")?.0;
let outcome = worker::Outcome { tag, source_key, result: Ok(patch), superseded: false };
let mut polled = Polled::default();
self.apply_outcome(outcome, &mut polled);
}
@@ -599,6 +606,22 @@ impl Session {
if o.superseded {
return;
}
let photo = match &o.tag {
Tag::Clicks { photo, .. } | Tag::NewPrompt { photo, .. } | Tag::Detail { photo, .. } => Some(*photo),
_ => None,
};
if let Some(photo) = photo
&& self.segment_key(photo).map(|(key, _)| key) != Some(o.source_key)
{
if let Tag::Clicks { photo, mask, comp, hint, exclude } = &o.tag {
let pending = PendingClicks { photo: *photo, mask: *mask, comp: *comp, hint: hint.clone(), exclude: exclude.clone() };
if self.segmenter.pending.as_ref() == Some(&pending) {
self.segmenter.pending = None;
}
}
polled.messages.push("The photo changed while the selection was being computed; try again.".into());
return;
}
match o.tag {
Tag::Wait => {}
Tag::Prepare => {
@@ -698,6 +721,71 @@ mod tests {
SegMask::from_logits(side, &l)
}
#[cfg(feature = "sam")]
#[test]
fn stale_prompt_result_does_not_add_a_mask_after_geometry_changes() {
let mut s = Session::with_demo();
let photo = s.active().unwrap();
let source_key = s.segment_key(photo).unwrap().0;
let outcome = worker::Outcome {
source_key,
tag: Tag::NewPrompt { photo, mask: None, op: "add".into(), name: None, text: "sky".into() },
result: Ok(Some(seg_box(16, 0, 0, 16, 8))),
superseded: false,
};
let mut d = (*s.develop_of(photo).unwrap()).clone();
d.geometry.horizontal = 30.0;
s.set_develop(photo, d, "Perspective").unwrap();
let mut polled = Polled::default();
s.apply_outcome(outcome, &mut polled);
assert!(s.develop_of(photo).unwrap().masks.is_empty(), "a mask computed in the old photo frame must not be applied");
assert!(!polled.changed);
assert!(polled.messages.iter().any(|m| m.contains("changed")));
}
#[cfg(feature = "sam")]
#[test]
fn current_prompt_result_is_applied_without_inference_or_model_weights() {
let mut s = Session::with_demo();
let photo = s.active().unwrap();
let source_key = s.segment_key(photo).unwrap().0;
let outcome = worker::Outcome {
source_key,
tag: Tag::NewPrompt { photo, mask: None, op: "add".into(), name: None, text: "sky".into() },
result: Ok(Some(seg_box(16, 0, 0, 16, 8))),
superseded: false,
};
let mut polled = Polled::default();
s.apply_outcome(outcome, &mut polled);
assert!(polled.changed && polled.messages.is_empty());
assert!(matches!(s.develop_of(photo).unwrap().masks[0].components[0].shape, MaskShape::Prompt { seg: Some(_), .. }));
}
#[cfg(feature = "sam")]
#[test]
fn stale_detail_result_does_not_overwrite_an_existing_mask() {
let mut s = Session::with_demo();
let photo = s.active().unwrap();
let seg = seg_box(16, 4, 4, 6, 6);
let shape = MaskShape::Prompt { text: "flower".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 };
s.execute("mask.add", &serde_json::json!({"kind":"prompt","text":"flower","seg":seg})).unwrap();
let source_key = s.segment_key(photo).unwrap().0;
let mask = s.active_mask.unwrap();
let mut d = (*s.develop_of(photo).unwrap()).clone();
d.orientation = lightcraft_geom::Orientation::Rotate90;
s.set_develop(photo, d, "Rotate").unwrap();
let outcome = worker::Outcome {
source_key,
tag: Tag::Detail { photo, mask, comp: 0, shape },
result: Ok(Some(seg_box(16, 5, 5, 7, 7))),
superseded: false,
};
let mut polled = Polled::default();
s.apply_outcome(outcome, &mut polled);
assert!(!polled.changed);
assert!(matches!(&s.develop_of(photo).unwrap().masks[0].components[0].shape,MaskShape::Prompt { detail,.. } if detail.is_empty()));
}
#[test]
fn detail_region_frames_the_selection_with_a_margin() {
// a small object in the middle of a square photo
+6 -3
View File
@@ -50,6 +50,8 @@ pub struct Job {
pub dir: PathBuf,
/// Which photo and look the request is about (the encode cache's key).
pub key: u64,
/// Identity of the full photo input, including for a detail job whose encode-cache key is zero.
pub source_key: u64,
/// Renders the model input when the cache doesn't hold `key` (always for a detail pass).
pub render: Option<RenderJob>,
/// The photo's file, when it is missing on disk (for a clearer error if rendering fails).
@@ -62,6 +64,7 @@ pub struct Job {
/// A finished request: the segmentation (`None`: nothing selected / nothing to report).
pub struct Outcome {
pub tag: Tag,
pub source_key: u64,
pub result: Result<Option<SegMask>, String>,
/// A newer request for the same selection came in; this one was skipped.
pub superseded: bool,
@@ -219,7 +222,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
let _ = job.reply.send(Outcome { tag: job.tag, source_key: job.source_key, result: Ok(None), superseded: true });
continue;
}
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
@@ -237,7 +240,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
}
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
let _ = job.reply.send(Outcome { tag: job.tag, source_key: job.source_key, result, superseded: false });
}
}
@@ -405,7 +408,7 @@ mod tests {
fn job(tag: Tag, reply: &mpsc::Sender<Outcome>) -> Job {
// an empty folder: loading fails with an error (no model files)
let dir = std::env::temp_dir().join(format!("lc-sam3-worker-none-{}", std::process::id()));
Job { dir, key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
Job { dir, key: 1, source_key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
}
#[test]
+123 -27
View File
@@ -107,6 +107,52 @@ pub struct SidecarData {
/// Actionable diagnostics for foreign develop settings that were not fully carried over.
/// These are surfaced in the photo import report and never persisted in the catalog.
pub warnings: Vec<crate::import::ImportWarning>,
/// Lightroom's saved Upright correction for the selected mode, waiting for the photo's frame
/// ([`SidecarData::set_frame`]); `None` when the packet has none LightCraft can apply.
pub upright: Option<crate::crs::SavedUpright>,
/// The stored image the packet's crop and saved Upright refer to, when the caller knows it.
pub frame: Option<StoredFrame>,
/// [`SidecarData::upright`] in the frame the file's orientation gives (before the photo's own
/// Rotate Left / Right, which [`merge_into`] adds): set by [`SidecarData::set_frame`].
pub placed_upright: Option<lightcraft_geom::Homography>,
}
/// A file's stored image: its size before orientation, and the orientation that turns it into the
/// image LightCraft shows before the photo's own Rotate Left / Right (the file's EXIF orientation,
/// as the decoder applies it). Lightroom's crop edges and saved Upright matrices refer to it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StoredFrame {
pub orientation: lightcraft_geom::Orientation,
pub width: f64,
pub height: f64,
}
impl StoredFrame {
/// The stored image of a file shown `shown_w × shown_h` after `orientation`.
pub fn of_shown(orientation: lightcraft_geom::Orientation, shown_w: f64, shown_h: f64) -> Option<StoredFrame> {
let ok = shown_w.is_finite() && shown_h.is_finite() && shown_w > 0.0 && shown_h > 0.0;
let (width, height) = if orientation.swaps_axes() { (shown_h, shown_w) } else { (shown_w, shown_h) };
ok.then_some(StoredFrame { orientation, width, height })
}
}
/// Whether partial settings `v` hold a crop that depends on the frame it is read in: anything but
/// "no crop" (the whole image, not straightened), which is the same in every orientation.
fn crop_needs_frame(v: &Value) -> bool {
let Some(g) = v.pointer("/crop/geometry") else { return false };
let at = |k: &str, want: f64| g.pointer(k).and_then(Value::as_f64).is_some_and(|x| x == want);
!(at("/rect/x0", 0.0)
&& at("/rect/y0", 0.0)
&& at("/rect/x1", 1.0)
&& at("/rect/y1", 1.0)
&& g.get("angle").and_then(Value::as_f64).is_none_or(|a| a == 0.0))
}
/// The stored image of the file at `path` as it is now, through the session's file probe `probe`
/// (the one import uses, inside its memory budget; never recorded: a file can change): `None` when
/// it can't be read.
pub fn probe_frame(probe: Option<&crate::media::FileProbe>, path: &str) -> Option<StoredFrame> {
probe?(path).ok()?.stored_frame()
}
#[derive(Clone, Debug, PartialEq)]
@@ -183,23 +229,10 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu
fields: Vec::new(),
});
}
let upright = crate::crs::has_saved_upright_transform(&d.properties);
if upright {
let mode = d
.properties
.get("crs:PerspectiveUpright")
.and_then(|values| values.first())
.map(String::as_str)
.map(|mode| match mode {
"0" => crate::import::ImportUprightMode::Off,
"1" => crate::import::ImportUprightMode::Auto,
"2" => crate::import::ImportUprightMode::Level,
"3" => crate::import::ImportUprightMode::Vertical,
"4" => crate::import::ImportUprightMode::Full,
"5" => crate::import::ImportUprightMode::Guided,
_ => crate::import::ImportUprightMode::Unknown,
})
.unwrap_or(crate::import::ImportUprightMode::Unknown);
// Lightroom's Upright: its saved matrix is kept when the photo's frame is known
// ([`SidecarData::set_frame`]); until then, and whenever it can't be (Guided's guides
// aren't read), the mode is solved again and the warning says so. Off loses nothing.
if let Some(mode) = crate::crs::upright_at_risk(&d.properties) {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UprightGeometry,
@@ -208,15 +241,9 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu
fields: Vec::new(),
});
}
let other: Vec<&str> = unmapped
.iter()
.map(String::as_str)
.filter(|name| {
*name != "CameraProfile"
&& !name.starts_with("UprightTransform_")
&& !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion")
})
.collect();
out.upright = crate::crs::saved_upright(&d.properties);
let other: Vec<&str> =
unmapped.iter().map(String::as_str).filter(|name| *name != "CameraProfile" && !name.starts_with("Upright")).collect();
if !other.is_empty() {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
@@ -280,6 +307,15 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
Some(DevelopPatch::Full(s)) => (**s).clone(),
Some(DevelopPatch::Partial(v)) => {
let mut v = v.clone();
if let Some(f) = sc.frame {
// the crop and Lightroom's saved Upright are on the stored image: into the frame the
// file's orientation and then the photo's own Rotate Left / Right give
let user = p.develop.orientation;
crate::crs::crop_in_frame(&mut v, f.orientation.then(user), f.width / f.height);
if let Some(h) = sc.placed_upright {
crate::crs::put_upright_transform(&mut v, lightcraft_pipeline::upright::turned(&h, user));
}
}
if p.width > 0 && p.height > 0 {
crate::crs_masks::refit_radials(&mut v, crate::crs_masks::DEFAULT_ASPECT, p.width as f64 / p.height as f64);
}
@@ -296,6 +332,55 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
}
impl SidecarData {
/// The photo's stored image (`None`: not known). With it, [`merge_into`] puts the packet's crop
/// into LightCraft's frame (as the Lightroom catalog import does) and keeps Lightroom's saved
/// Upright correction, so the `uprightGeometry` warning is dropped. Without it, or for a
/// straightened crop on top of a saved Upright (not measured yet), the mode is solved again and
/// the warning stays. Returns whether the saved Upright will be kept.
pub fn set_frame(&mut self, frame: Option<StoredFrame>) -> bool {
self.frame = frame.filter(|f| f.width.is_finite() && f.height.is_finite() && f.width > 0.0 && f.height > 0.0);
self.placed_upright = None;
let (Some(saved), Some(f), Some(DevelopPatch::Partial(partial))) = (self.upright, self.frame, self.develop.as_ref()) else {
return false;
};
if partial.pointer("/crop/geometry/angle").and_then(Value::as_f64).is_some_and(|a| a != 0.0) {
return false;
}
self.placed_upright = crate::crs::upright_in_frame(&saved, f.orientation, f.width, f.height);
if self.placed_upright.is_some() {
self.warnings.retain(|w| w.code != crate::import::ImportWarningCode::UprightGeometry);
}
self.placed_upright.is_some()
}
/// Whether the packet's crop or saved Upright needs the photo's stored frame. "No crop" (the
/// whole image, not straightened) is the same in every frame.
pub fn needs_frame(&self) -> bool {
self.upright.is_some() || matches!(&self.develop, Some(DevelopPatch::Partial(v)) if crop_needs_frame(v))
}
/// For a photo already in the library whose file's orientation can't be learnt: leave its crop
/// as it is rather than take Lightroom's crop edges in an unknown frame, and say so. Returns
/// the crop held (as the packet wrote it).
pub fn hold_unframed_crop(&mut self) -> Option<Value> {
if self.frame.is_some() {
return None;
}
let Some(DevelopPatch::Partial(partial)) = self.develop.as_mut() else { return None };
if !crop_needs_frame(partial) {
return None;
}
let held = partial.as_object_mut()?.remove("crop")?;
self.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UnmappedXmp,
profile: None,
mode: None,
fields: vec!["Crop (the file's orientation couldn't be read)".into()],
});
Some(held)
}
/// Read the label through the catalog's label names: other editors write the label's name
/// (a custom set's "To Do" as well as "Red"); unknown names clear the label.
pub fn resolve_label(mut self, cat: &lightcraft_catalog::Catalog) -> Self {
@@ -591,7 +676,18 @@ 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, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
let mut sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
if sc.needs_frame() {
// Lightroom's crop and saved Upright are on the stored image: the file, as it is now,
// says how it is oriented
let frame = crate::import::probe_paths(self, &[orig.to_string()])
.into_iter()
.next()
.and_then(std::result::Result::ok)
.and_then(|i| i.stored_frame());
sc.set_frame(frame);
sc.hold_unframed_crop();
}
let mut warnings = sc.warnings.clone();
for warning in &mut warnings {
warning.path = orig.to_string();
+92 -24
View File
@@ -78,6 +78,9 @@ pub struct SyncInput {
photos: Vec<(Arc<Photo>, SidecarNaming)>,
/// For the colour label names a sidecar may use.
labels: Catalog,
/// The session's file probe (import's, inside its memory budget): how a sidecar's crop and
/// saved Upright learn the file's orientation.
probe: Option<crate::media::FileProbe>,
}
/// What changed in a folder since it came into the library.
@@ -150,6 +153,8 @@ pub struct SyncReport {
pub read: usize,
/// (path or photo, why) for what couldn't be done.
pub failed: Vec<(String, String)>,
/// What sidecars said that couldn't be carried over in full (as for import).
pub warnings: Vec<crate::import::ImportWarning>,
}
fn invalid(msg: impl Into<String>) -> EngineError {
@@ -179,7 +184,7 @@ impl SyncInput {
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() })
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone(), probe: s.media.file_probe.clone() })
}
}
@@ -190,19 +195,21 @@ impl SyncInput {
/// 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 SyncInput { folder, scan, photos, labels, probe } = 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));
let (out, checks) =
(crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, probe.as_ref(), 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 probe = probe.as_ref();
let checks = sc.spawn(move || check_photos(photos, labels, probe, 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())
@@ -243,9 +250,18 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
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.
/// Whether a photo is the same file in the same place in the library: what decides whether it
/// is missing or moved, and which files beside it are new. Its rating, keywords and edits are
/// the person's work, not the folder's state.
fn same_file(a: &Arc<Photo>, b: &Arc<Photo>) -> bool {
Arc::ptr_eq(a, b)
|| (&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size)
== (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size)
}
/// Whether a photo is the same as far as reading its sidecar 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;
@@ -327,11 +343,11 @@ enum Check {
News,
}
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, probe: Option<&crate::media::FileProbe>) -> 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) {
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, probe) {
Check::News
} else {
Check::Nothing
@@ -340,12 +356,17 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
/// [`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> {
fn check_photos(
photos: &[(Arc<Photo>, SidecarNaming)],
labels: &Catalog,
probe: Option<&crate::media::FileProbe>,
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);
let c = check_photo(p, naming, labels, probe);
progress.checked.fetch_add(1, Relaxed);
c
};
@@ -394,26 +415,38 @@ fn not_there(path: &str) -> EngineError {
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.)
/// the folder holds the same photos (virtual copies aside), each the same file in the same
/// place ([`same_file`]).
/// What the person was shown is what gets done. Used once.
///
/// Work on the folder's photos since (ratings, labels, keywords, edits) leaves the scan
/// current: none of it changes which files are new, missing or moved, and a scan of a big
/// folder on a slow disk takes minutes in which the person is told to go on working. The one
/// thing such work does matter to is a sidecar the scan found news in: reading it would go
/// over that work, so for those photos nothing may have changed ([`same_for_sync`]).
/// (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();
// (virtual copies made or deleted meanwhile are not files: the folder holds what it held)
let mut now: Vec<PhotoId> =
self.catalog.query(&f, &Sort::default()).into_iter().filter(|id| self.catalog.photo(*id).is_some_and(|p| p.copy_of.is_none())).collect();
let mut then: Vec<PhotoId> = c.seen.iter().filter(|p| p.copy_of.is_none()).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)));
let read: std::collections::HashSet<u64> = c.metadata.iter().map(|m| m.id).collect();
let same = |p: &Arc<Photo>, q: &Arc<Photo>| if read.contains(&p.id.0) { same_for_sync(p, q) } else { same_file(p, q) };
let unchanged = now == then && c.seen.iter().filter(|p| p.copy_of.is_none()).all(|p| self.catalog.photo(p.id).is_some_and(|q| same(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 {
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, probe: Option<&crate::media::FileProbe>) -> 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 };
@@ -427,7 +460,14 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool
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 Ok(mut sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false };
// compared in the photo's frame, as it would be read; a crop that can't be placed (no frame) and
// differs from the photo's is still news, so the synchronize that reads it says why it was left
if let Some(held) = framed(&mut sc, path, probe)
&& lightcraft_develop::apply_partial(&p.develop, &serde_json::json!({"crop": held}), 1.0).crop != p.develop.crop
{
return true;
}
let sc = sc.resolve_label(labels);
let mut q = p.clone();
crate::sidecar::merge_into(&mut q, &sc, "");
@@ -463,8 +503,10 @@ pub struct SyncWork {
import: Option<(crate::import::ImportJob, Vec<String>)>,
moved: Vec<FolderMoved>,
missing: Vec<FolderPhoto>,
/// (photo, its file, how its sidecar is named)
/// (photo, its file, sidecar naming, white balance target)
sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>,
/// The session's file probe, for the files' orientations ([`SyncInput`]).
probe: Option<crate::media::FileProbe>,
}
/// The catalog half of a synchronize (see [`SyncJob`]).
@@ -529,6 +571,7 @@ impl SyncJob {
moved: if choice.relink_moved { moved } else { Vec::new() },
missing: if choice.remove_missing { missing } else { Vec::new() },
sidecars,
probe: s.media.file_probe.clone(),
};
let commit = SyncCommit {
opts,
@@ -550,7 +593,7 @@ impl SyncWork {
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 SyncWork { import, moved, missing, sidecars, probe } = 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);
@@ -594,7 +637,7 @@ impl SyncWork {
if cancel.load(Relaxed) {
break;
}
read.push((id, path.clone(), read_sidecar(&path, naming, target)));
read.push((id, path.clone(), read_sidecar(&path, naming, target, probe.as_ref())));
step(1);
}
if !read.is_empty() {
@@ -603,15 +646,34 @@ impl SyncWork {
}
}
/// 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> {
/// Read and parse photo file `path`'s XMP sidecar file, its crop and saved Upright in the frame the
/// file has now, as import reads them.
fn read_sidecar(
path: &str,
naming: SidecarNaming,
target: crate::crs::Target,
probe: Option<&crate::media::FileProbe>,
) -> 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()))
let mut sc = crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display()))?;
framed(&mut sc, path, probe);
Ok(sc)
}
/// Give a parsed sidecar of the photo whose file is `path` the frame the file has now, through the
/// session's file probe (only when the packet's crop or saved Upright needs it). Without one, the
/// photo's crop is left as it is and the report says so; returns the crop held that way.
fn framed(sc: &mut crate::sidecar::SidecarData, path: &str, probe: Option<&crate::media::FileProbe>) -> Option<serde_json::Value> {
if !sc.needs_frame() {
return None;
}
sc.set_frame(crate::sidecar::probe_frame(probe, path));
sc.hold_unframed_crop()
}
impl SyncCommit {
@@ -671,6 +733,12 @@ impl SyncCommit {
SyncStep::Sidecars(read) => {
let mut ops = Vec::new();
for (id, path, sc) in read {
if let Ok(sc) = &sc {
report.warnings.extend(sc.warnings.iter().cloned().map(|mut w| {
w.path = path.clone();
w
}));
}
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())),
+216
View File
@@ -0,0 +1,216 @@
//! Auto Import with subfolders (issue #1109): off by default (the watched folder's top level
//! only); on, its subfolders' photos too, never hidden folders, the library's own folder or
//! folder links, within bounds that end in a warning.
use std::path::{Path, PathBuf};
use serde_json::json;
use crate::Session;
use crate::cmd::library::AutoImportWalk;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-autoimport-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A small procedural PNG (distinct per seed).
fn write_png(path: &Path, seed: u8) {
let (w, h) = (24usize, 16usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
/// Two scans: a file is imported once its size held between them.
fn scan_twice(s: &mut Session) -> serde_json::Value {
s.execute("library.autoImportScan", &json!({})).unwrap();
s.execute("library.autoImportScan", &json!({})).unwrap()
}
fn names(files: &[(String, u64)], root: &Path) -> Vec<String> {
let mut v: Vec<String> = files.iter().map(|(p, _)| Path::new(p).strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect();
v.sort();
v
}
#[test]
fn nested_photos_are_imported_only_with_subfolders_on() {
let dir = temp_dir("nested");
write_png(&dir.join("top.png"), 1);
write_png(&dir.join("2026").join("10").join("deep.png"), 2);
let mut s = Session::new().with_fs();
let r = s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy()})).unwrap();
assert_eq!(r["subfolders"], false, "off by default: {r}");
let r = scan_twice(&mut s);
assert_eq!(r["subfolders"], false, "{r}");
assert_eq!(s.catalog.len(), 1, "the top level only: {r}");
for _ in 0..2 {
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
}
let r = s.execute("library.autoImport", &json!({"subfolders": true})).unwrap();
assert_eq!(r["subfolders"], true, "{r}");
let r = scan_twice(&mut s);
assert_eq!(r["subfolders"], true, "{r}");
assert_eq!(r["imported"].as_array().map(Vec::len), Some(1), "the nested photo: {r}");
assert_eq!(s.catalog.len(), 2);
// a file still growing in a subfolder waits, as at the top level
write_png(&dir.join("2026").join("11").join("new.png"), 3);
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"].as_array().map(Vec::len), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_setting_persists_with_the_library() {
let dir = temp_dir("persist");
let lib = dir.join("lib");
let watched = dir.join("watched");
std::fs::create_dir_all(&watched).unwrap();
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(!s.import_defaults.auto_subfolders, "off by default");
s.execute("library.autoImport", &json!({"folder": watched.to_string_lossy(), "subfolders": true})).unwrap();
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(s.import_defaults.auto_subfolders);
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["subfolders"], true);
// other settings leave it alone; turning it off sticks too
let r = s.execute("library.autoImport", &json!({"copy": true})).unwrap();
assert_eq!(r["subfolders"], true, "{r}");
s.execute("library.autoImport", &json!({"subfolders": false})).unwrap();
drop(s);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(!s.import_defaults.auto_subfolders);
let _ = std::fs::remove_dir_all(&dir);
}
/// Hidden folders and the library's own folder (where copy-mode arrivals go) are never listed,
/// so copies are never imported again.
#[test]
fn hidden_folders_and_the_library_are_skipped() {
let dir = temp_dir("skip");
let lib = dir.join("Library");
write_png(&dir.join("shoot").join("a.png"), 1);
write_png(&dir.join(".cache").join("h.png"), 2);
write_png(&dir.join("shoot").join(".h.png"), 3);
std::fs::write(dir.join("shoot").join("notes.txt"), "not a photo").unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "copy": true, "subfolders": true})).unwrap();
let r = scan_twice(&mut s);
assert_eq!(r["imported"].as_array().map(Vec::len), Some(1), "{r}");
assert!(lib.join("Originals").is_dir(), "copied into the library inside the watched folder");
let listed = AutoImportWalk::of(&s).unwrap().list().unwrap();
assert_eq!(names(&listed.files, &dir), ["shoot/a.png"], "{listed:?}");
assert!(listed.warnings.is_empty(), "{listed:?}");
for _ in 0..3 {
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
}
assert_eq!(s.catalog.len(), 1, "the copy isn't imported again");
let _ = std::fs::remove_dir_all(&dir);
}
/// The depth and entry bounds hold, each with a warning, so a huge tree can't stall the scan.
#[test]
fn bounds_stop_the_listing_with_a_warning() {
let dir = temp_dir("bounds");
let mut deep = dir.clone();
for i in 0..5 {
deep = deep.join(format!("l{i}"));
write_png(&deep.join(format!("p{i}.png")), i);
}
let mut w = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new());
let all = w.list().unwrap();
assert_eq!(all.files.len(), 5);
assert!(all.warnings.is_empty(), "{all:?}");
w.max_depth = 3;
let l = w.list().unwrap();
assert_eq!(names(&l.files, &dir), ["l0/l1/p1.png", "l0/p0.png"], "three levels of entries listed");
assert!(l.warnings.iter().any(|w| w.contains("3 folder levels")), "{l:?}");
w.max_depth = 16;
w.max_entries = 4;
let l = w.list().unwrap();
assert!(l.files.len() < 5, "{l:?}");
assert!(l.warnings.iter().any(|w| w.contains("after 4 files and folders")), "{l:?}");
// the warnings reach the scan's result
let mut s = Session::new().with_fs();
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "subfolders": true})).unwrap();
let r = s.execute("library.autoImportScan", &json!({"listing": [], "listingWarnings": ["stopped"]})).unwrap();
assert_eq!(r["listingWarnings"], json!(["stopped"]), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The default bounds: 16 levels, 200,000 entries.
#[test]
fn default_bounds() {
let w = AutoImportWalk::new("x", true, Vec::new());
assert_eq!((w.max_depth, w.max_entries), (16, 200_000));
}
/// A watched folder that's gone is an error for the scan (as before); a subfolder that can't be
/// read is skipped with a warning.
#[test]
fn a_missing_folder_errs_and_an_unreadable_subfolder_warns() {
let dir = temp_dir("unreadable");
let w = AutoImportWalk::new(&dir.join("gone").to_string_lossy(), true, Vec::new());
assert!(w.list().is_err());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
write_png(&dir.join("ok").join("a.png"), 1);
let locked = dir.join("locked");
write_png(&locked.join("b.png"), 2);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let l = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new()).list().unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
// (root reads it anyway)
if !names(&l.files, &dir).contains(&"locked/b.png".to_string()) {
assert_eq!(names(&l.files, &dir), ["ok/a.png"]);
assert!(l.warnings.iter().any(|w| w.contains("locked")), "{l:?}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Links to folders aren't followed (a link back up would loop); links to files are files.
#[cfg(unix)]
#[test]
fn folder_links_are_not_followed() {
let dir = temp_dir("links");
let outside = temp_dir("links-outside");
write_png(&outside.join("o.png"), 1);
write_png(&dir.join("sub").join("a.png"), 2);
std::os::unix::fs::symlink(&outside, dir.join("sub").join("elsewhere")).unwrap();
std::os::unix::fs::symlink(&dir, dir.join("sub").join("loop")).unwrap();
std::os::unix::fs::symlink(outside.join("o.png"), dir.join("sub").join("linked.png")).unwrap();
let l = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new()).list().unwrap();
assert_eq!(names(&l.files, &dir), ["sub/a.png", "sub/linked.png"], "{l:?}");
assert!(l.warnings.is_empty(), "{l:?}");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// A name that isn't valid Unicode is skipped with a warning, not a crash or a mangled path.
#[cfg(target_os = "linux")]
#[test]
fn non_unicode_names_are_skipped() {
use std::os::unix::ffi::OsStrExt;
let dir = temp_dir("names");
write_png(&dir.join("sub").join("a.png"), 1);
let odd = dir.join("sub").join(std::ffi::OsStr::from_bytes(b"bad\xff.png"));
if std::fs::write(&odd, b"x").is_err() {
return; // a file system that refuses such names
}
let l = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new()).list().unwrap();
assert_eq!(names(&l.files, &dir), ["sub/a.png"], "{l:?}");
assert!(l.warnings.iter().any(|w| w.contains("valid Unicode")), "{l:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+56 -1
View File
@@ -470,6 +470,23 @@ fn hdr_avif_export_hlg() {
assert!(!hdr.bytes.windows(4).any(|w| w == b"clli"));
}
/// An exported AVIF's metadata reads back (issue #693: HEIF-family files had no metadata reader).
/// avif-serialize writes the EXIF as an `Exif` item in two extents, its offset field and the TIFF.
#[test]
fn exported_avif_metadata_reads_back() {
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.captured = Some("2026-10-12T12:34:56".into());
let m = crate::export::export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap();
let img = lightcraft_raster::Rgba8::new(48, 32);
let avif = crate::export::encode_with_metadata(&img, &ExportOptions::from_json(&json!({"format": "avif", "quality": 60})), Some(&m)).unwrap();
assert_eq!(lightcraft_codecs::sniff(&avif), Some(lightcraft_codecs::Format::Avif));
let back = lightcraft_meta::extract(&avif);
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
assert_eq!(back.capture_time.map(|d| d.to_iso()).as_deref(), Some("2026-10-12T12:34:56"));
}
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
#[test]
@@ -513,6 +530,7 @@ fn remove_location_holds_for_original_and_dng_exports() {
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap();
assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}");
assert!(!err.contains("DNG"), "a JPEG can't be exported as DNG: {err}");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -529,7 +547,44 @@ fn remove_location_refuses_a_raw_that_embeds_gps() {
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
let id = s.catalog.photos().next().unwrap().id;
let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap();
assert!(err.contains("can't remove the location"), "{err}");
assert!(err.contains("can't remove the location") && err.contains("DNG"), "a raw can go out as DNG: {err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A HEIC whose only GPS is in its thumbnail's EXIF (the pictures have no data: an original's
/// export never decodes it). Item 1 is the photo, 2 its thumbnail, 3 the thumbnail's EXIF, kept in
/// the `meta` box's `idat`.
fn heic_with_gps_in_its_thumbnail() -> Vec<u8> {
let bx = |kind: &[u8], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat();
let full = |kind: &[u8], body: &[u8]| bx(kind, &[&[0u8, 0, 0, 0][..], body].concat());
let gps = lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None };
let exif = [&[0u8, 0, 0, 0][..], &lightcraft_meta::write_exif(&lightcraft_meta::Metadata { gps: Some(gps), ..Default::default() })].concat();
let infe = |id: u8, kind: &[u8]| bx(b"infe", &[&[2, 0, 0, 0, 0, id, 0, 0][..], kind, &[0]].concat());
let iinf = full(b"iinf", &[&[0, 3][..], &infe(1, b"hvc1"), &infe(2, b"hvc1"), &infe(3, b"Exif")].concat());
let iref = full(b"iref", &[bx(b"thmb", &[0, 2, 0, 1, 0, 1]), bx(b"cdsc", &[0, 3, 0, 1, 0, 2])].concat());
// `iloc` version 1, 4-byte offsets and lengths: item 3 from `idat` (construction method 1)
let mut iloc = vec![1, 0, 0, 0, 0x44, 0, 0, 1, 0, 3, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0];
iloc.extend_from_slice(&(exif.len() as u32).to_be_bytes());
let meta = full(b"meta", &[full(b"pitm", &[0, 1]), iinf, iref, bx(b"iloc", &iloc), bx(b"idat", &exif)].concat());
[bx(b"ftyp", b"heic\0\0\0\0mif1heic"), meta].concat()
}
/// "Remove location info" refuses an original that embeds GPS anywhere it can be read, here only
/// in a HEIC's thumbnail (#693 review), and doesn't offer DNG for a photo that isn't a raw.
#[test]
fn remove_location_refuses_a_heic_whose_thumbnail_has_gps() {
use lightcraft_catalog::{Op, Photo, Source};
let dir = temp_dir("export-noloc-heic");
let path = dir.join("IMG_0003.HEIC");
std::fs::write(&path, heic_with_gps_in_its_thumbnail()).unwrap();
let mut s = Session::new().with_fs();
let id = s.catalog.alloc_photo_id();
let p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, "IMG_0003.HEIC", "HEIC", 32, 32, "2026-10-12T00:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let opts = |remove: bool| ExportOptions::from_json(&json!({"format": "original", "removeLocation": remove}));
assert!(export_photo(&mut s, id, &opts(false), 1).is_ok(), "copied as is when the location may stay");
let err = export_photo(&mut s, id, &opts(true), 1).err().expect("refused: the thumbnail's EXIF has GPS");
assert!(err.contains("can't remove the location") && err.contains("export as JPEG or TIFF"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
+131 -1
View File
@@ -30,12 +30,32 @@ fn ids(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
#[test]
fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() {
let src = temp_dir("sony-discovery");
std::fs::create_dir_all(src.join("trip").join("day2")).unwrap();
let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"];
let mut expected: Vec<String> =
names.iter().map(|name| name.split('/').fold(src.clone(), |p, part| p.join(part)).to_string_lossy().into_owned()).collect();
for path in &expected {
std::fs::write(path, b"discovery only, not a decoded photo").unwrap();
}
std::fs::write(src.join("trip/notes.txt"), b"not a photo").unwrap();
let mut found = crate::import::expand(&[src.to_string_lossy().into_owned()], None);
found.sort();
expected.sort();
assert_eq!(found, expected);
assert_eq!(crate::import::expand(&expected, None), expected, "folder and direct discovery agree");
std::fs::remove_dir_all(src).unwrap();
}
#[test]
fn recursive_import_with_duplicates() {
let src = temp_dir("src");
write_png(&src.join("a.png"), 1);
write_png(&src.join("trip/b.png"), 2);
write_png(&src.join("trip/day2/c.PNG"), 3);
write_png(&src.join("trip").join("day2").join("c.PNG"), 3);
std::fs::copy(src.join("a.png"), src.join("trip/a-copy.png")).unwrap(); // same bytes
write_png(&src.join(".hidden/x.png"), 9);
std::fs::write(src.join("notes.txt"), "not a photo").unwrap();
@@ -889,6 +909,82 @@ fn smart_previews_folder_is_chosen_per_library() {
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
}
/// The thumbnail cache can live outside the library, like smart previews (issue #903): the
/// folder is saved per library, relative paths and missing drives are refused, the thumbnails
/// already cached need an explicit choice, and a chosen folder that is gone when the library
/// opens is reported (never a silent fallback to the library drive, nothing created there).
#[test]
fn thumbnail_cache_folder_is_chosen_per_library() {
let lib = temp_dir("thumbloc-lib");
let away = temp_dir("thumbloc-away");
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
const C: &str = "library.thumbnailCacheLocation";
let loc = s.execute(C, &json!({})).unwrap();
assert_eq!((loc["custom"].clone(), loc["available"].clone()), (json!(false), json!(true)), "{loc}");
assert_eq!(loc["path"], lib.join("thumbs").to_string_lossy().as_ref());
assert_eq!(loc["default"], lib.join("thumbs").to_string_lossy().as_ref());
// a cached thumbnail in the library's folder
let key = lightcraft_preview::hash_bytes(b"thumb");
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[90, 120, 200, 255]; 16] };
s.media.rendered.disk().unwrap().put(key, &img);
assert_eq!(s.execute(C, &json!({})).unwrap()["count"], 1);
// relative paths and missing drives are refused, and nothing is created on this drive
let err = s.execute(C, &json!({"path": "rel/thumbs"})).unwrap_err().to_string();
assert!(err.contains("absolute"), "{err}");
let gone = away.join("unplugged/thumbs");
let err = s.execute(C, &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("not available"), "{err}");
assert!(!away.join("unplugged").exists());
// the thumbnails already cached need an explicit choice
let to = away.join("thumbs");
let err = s.execute(C, &json!({"path": to.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("existing"), "{err}");
assert_eq!(s.execute(C, &json!({})).unwrap()["custom"], false);
let r = s.execute(C, &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap();
assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}");
assert_eq!(s.media.rendered.disk().unwrap().dir(), to.as_path(), "new thumbnails go to the chosen folder");
assert!(s.media.rendered.disk().unwrap().get(key).is_some(), "the moved thumbnail is used");
assert_eq!(lightcraft_preview::disk::cache_stats(&lib.join("thumbs")).0, 0);
// the setting is saved with the library
drop(s); // one session per library (issue #99)
let mut again = Session::new().with_fs();
again.open_library(&lib, false).unwrap();
assert!(again.take_library_warnings().is_empty());
assert_eq!(again.execute(C, &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
assert_eq!(again.media.rendered.disk().unwrap().dir(), to.as_path());
// leaving the old thumbnails keeps them where they are; the new folder fills as the grid renders
let drive = away.join("drive");
std::fs::create_dir_all(&drive).unwrap();
let on_drive = drive.join("thumbs");
let r = again.execute(C, &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap();
assert_eq!((r["handled"].clone(), r["count"].clone()), (json!(0), json!(0)), "{r}");
assert_eq!(lightcraft_preview::disk::cache_stats(&to).0, 1, "left in the old folder");
// the chosen folder is gone when the library opens (its drive's mount point is still
// there): reported, and the folder is not created again
drop(again);
std::fs::remove_dir_all(&on_drive).unwrap();
let mut third = Session::new().with_fs();
third.open_library(&lib, false).unwrap();
let warnings = third.take_library_warnings();
assert!(warnings.iter().any(|w| w.contains("not available") && w.contains("thumbs")), "{warnings:?}");
let loc = third.execute(C, &json!({})).unwrap();
assert_eq!((loc["available"].clone(), loc["path"].clone()), (json!(false), json!(on_drive.to_string_lossy())), "{loc}");
third.media.rendered.disk().unwrap().put(key, &img);
assert!(!on_drive.exists(), "the missing chosen folder is not recreated");
// back to the library's own folder
let r = third.execute(C, &json!({"reset": true})).unwrap();
assert_eq!((r["custom"].clone(), r["available"].clone()), (json!(false), json!(true)), "{r}");
assert_eq!(r["path"], lib.join("thumbs").to_string_lossy().as_ref());
assert_eq!(third.media.rendered.disk().unwrap().dir(), lib.join("thumbs").as_path());
}
/// Missing Photos covers library photos only: a small library next to many Local browse
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
#[test]
@@ -1536,6 +1632,40 @@ fn heic_imports_as_a_normal_photo_or_says_why_not() {
let _ = std::fs::remove_dir_all(&src);
}
/// Issue #693: a HEIC's EXIF reaches the catalog (camera, capture time) and All Metadata, as a
/// JPEG's does. The file is made like the issue sweep's `synthetic-metadata.heic`: the HEIF test
/// builder, 96 × 64, with an EXIF block naming the camera and the capture time. The container is
/// read without the decoder; importing needs the codecs' `heif` feature (`cargo xtask ci` runs
/// this test with it).
#[test]
fn heic_exif_reaches_the_catalog_and_all_metadata() {
use lightcraft_heif::testdata::{Spec, build};
let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150];
let exif = lightcraft_meta::write_exif(&lightcraft_meta::Metadata {
make: Some("Synthetic".into()),
model: Some("Issue sweep camera".into()),
capture_time: lightcraft_meta::DateTime::parse_iso("2026-10-12T12:34:56"),
..Default::default()
});
let bytes = build(&Spec { exif: Some(exif), ..Spec::new(96, 64, &photo) });
let m = lightcraft_meta::extract(&bytes);
assert_eq!(m.model.as_deref(), Some("Issue sweep camera"));
assert_eq!(m.capture_time.map(|d| d.to_iso()).as_deref(), Some("2026-10-12T12:34:56"));
if !lightcraft_codecs::Format::Heif.can_decode() {
return; // importing needs the decoder; `heic_imports_as_a_normal_photo_or_says_why_not` checks the error
}
let src = temp_dir("heic-exif");
std::fs::write(src.join("IMG_0002.HEIC"), &bytes).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
let p = s.catalog.photos().next().unwrap().clone();
assert_eq!((p.meta.camera.as_str(), p.captured.as_deref()), ("Synthetic Issue sweep camera", Some("2026-10-12T12:34:56")));
let all = s.execute("photo.allMetadata", &json!({"id": p.id.0})).unwrap();
assert!(all["exif"].as_array().unwrap().iter().any(|r| r["name"] == "Model" && r["value"] == "Issue sweep camera"), "{all}");
let _ = std::fs::remove_dir_all(&src);
}
/// Issue #705: Nikon (and most cameras) write `.JPG` and `.NEF`, and a user may rename to `.Jpg`.
/// The folder scan, `library.browse` and the sidecar lookup match extensions in any letter
/// case. (The file dialog's filters, the one place that was case-sensitive on Linux before
+162
View File
@@ -0,0 +1,162 @@
//! What a develop edit invalidates: a slider tick changes one photo's develop settings, so the
//! library-wide grid query only runs again when the current filter, sort or smart album reads
//! develop state (see `lightcraft_catalog::revision`).
use lightcraft_catalog::{Album, AlbumId, Filter, Op, Photo, PhotoId, RuleSet, Sort, SortKey, Source};
use serde_json::json;
use crate::{LibrarySource, Session};
/// A library of `n` photos with capture dates and a keyword each.
fn library(n: u64) -> Session {
let mut s = Session::new();
let ops = (0..n)
.map(|i| {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{i:06}.jpg"),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T10:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28));
p.meta.keywords = vec![format!("kw{}", i % 300)];
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
s.commit("Add", Op::Batch { ops }).unwrap();
s
}
/// Change photo `id`'s exposure (a committed edit, or a slider tick inside an interaction).
fn expose(s: &mut Session, id: PhotoId, ev: f64) {
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = ev;
s.set_develop(id, d, "Exposure").unwrap();
}
fn smart(s: &mut Session, id: u64, rules: serde_json::Value) {
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
let rules: RuleSet = serde_json::from_value(rules).unwrap();
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
s.commit("New Smart Album", Op::AddAlbum { album: a }).unwrap();
}
/// Slider ticks and committed develop edits leave the grid's list as it is (same generation: the
/// query didn't run) when nothing in view reads develop state; a library change still redoes it.
#[test]
fn develop_edits_dont_redo_the_library_query() {
let mut s = library(50);
let (generation, _) = s.visible_shared();
let total = s.source_total();
let id = s.visible()[7];
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
for i in 0..10 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
assert_eq!(s.visible_shared().0, generation, "tick {i}");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
expose(&mut s, id, 2.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.visible_shared().0, generation, "a committed edit and its undo");
assert_eq!(s.source_total(), total);
// a library change does
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
assert_ne!(s.visible_shared().0, generation);
}
/// Filters, sorts and smart albums that read develop state follow every develop edit, slider
/// ticks included.
#[test]
fn views_that_read_develop_state_follow_develop_edits() {
let mut s = library(20);
let ids = s.visible().to_vec();
let (a, b) = (ids[3], ids[11]);
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
// the filter bar's Edited, during a drag
s.filter = Filter { edited: Some(true), ..Default::default() };
assert!(s.visible().is_empty());
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
assert_eq!(s.visible(), [a], "a slider tick makes the photo edited");
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.0})).unwrap();
assert!(s.visible().is_empty(), "and back");
s.execute("develop.endInteraction", &json!({})).unwrap();
// a search token
s.filter = Filter { text: "edited:yes".into(), ..Default::default() };
expose(&mut s, b, 1.0);
assert_eq!(s.visible(), [b]);
// Edit Date: the photo edited last comes first
s.filter = Filter::default();
s.sort = Sort { key: SortKey::EditDate, ascending: false, ..Default::default() };
s.clock = Box::new(|| "2100-05-01T10:00:00".to_string());
expose(&mut s, a, 0.3);
assert_eq!(s.visible()[0], a);
s.clock = Box::new(|| "2100-05-01T11:00:00".to_string());
expose(&mut s, b, 0.7);
assert_eq!(s.visible()[0], b);
// a smart album testing Cropped, and one testing it through an Album rule
s.sort = Sort::default();
smart(&mut s, 100, json!({"rules": [{"field": "cropped", "op": "is", "value": true}]}));
smart(&mut s, 101, json!({"rules": [{"field": "album", "op": "is", "value": 100}]}));
for album in [100, 101] {
s.source = LibrarySource::Album(AlbumId(album));
assert!(s.visible().is_empty());
assert_eq!(s.source_total(), Some(0));
}
let mut d = (*s.develop_of(a).unwrap()).clone();
d.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.9);
s.set_develop(a, d, "Crop").unwrap();
for album in [100, 101] {
s.source = LibrarySource::Album(AlbumId(album));
assert_eq!(s.visible(), [a], "album {album}");
assert_eq!(s.source_total(), Some(1), "album {album}");
}
// and the size rules (a crop changes the shown shape)
s.source = LibrarySource::All;
let square: RuleSet = serde_json::from_value(json!({"rules": [{"field": "aspect", "op": "is", "value": "square"}]})).unwrap();
s.filter = Filter { rule_set: Some(square), ..Default::default() };
assert!(s.visible().is_empty());
let mut d = (*s.develop_of(b).unwrap()).clone();
d.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, 4000.0 / 6000.0, 1.0);
s.set_develop(b, d, "Crop").unwrap();
assert_eq!(s.visible(), [b]);
}
/// Per-tick cost of a slider drag on a big library (the develop op plus the grid query the
/// filmstrip asks for every frame):
/// `cargo test --release -p lightcraft-engine -- --ignored drag_tick_100k --nocapture`.
#[test]
#[ignore]
fn drag_tick_100k() {
let mut s = library(100_000);
let id = s.visible()[50_000];
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
let n = 60;
let tick = |s: &mut Session, name: &str| {
s.visible();
let t = std::time::Instant::now();
for i in 0..n {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 100.0})).unwrap();
s.visible();
}
let ms = t.elapsed().as_secs_f64() * 1e3 / n as f64;
eprintln!("slider tick at 100k photos, {name}: {ms:.3} ms (develop.set + visible)");
};
tick(&mut s, "warm-up");
tick(&mut s, "by capture date");
s.sort = Sort { key: SortKey::FileName, ..Default::default() };
tick(&mut s, "by file name");
// a view that reads develop state still asks again on every tick
s.sort = Sort::default();
s.filter = Filter { edited: Some(false), ..Default::default() };
tick(&mut s, "Unedited filter");
s.execute("develop.endInteraction", &json!({})).unwrap();
}
+126
View File
@@ -0,0 +1,126 @@
//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays
//! responsive: commands that don't touch presets never re-serialize them, and opening or importing
//! doesn't compare every preset with every other.
use std::path::PathBuf;
use std::time::Instant;
use lightcraft_develop::Preset;
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn many(n: usize) -> Vec<Preset> {
(0..n)
.map(|i| Preset {
id: format!("user.coll-{}.look-{i}", i / 100),
name: format!("Look {i}"),
group: format!("Collection {}", i / 100),
settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}),
favorite: false,
builtin: false,
})
.collect()
}
#[test]
fn develop_commands_do_not_reserialize_unchanged_presets() {
let lib = temp_dir("no-reserialize");
let mut s = Session::new();
s.open_library(&lib, true).unwrap();
let added = s.add_presets(many(2000));
assert_eq!(added.len(), 2000);
s.persist().unwrap();
assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved");
let before = crate::library::presets_serialized();
for i in 0..20 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
}
assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone");
// a preset change is still saved
let pid = added[7].clone();
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
assert!(crate::library::presets_serialized() > before);
s.close_library().unwrap();
drop(s);
let mut t = Session::new();
t.open_library(&lib, false).unwrap();
assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted");
assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn preset_list_generation_follows_mutable_access_only() {
let mut s = Session::new();
let g0 = s.presets.generation();
let _ = s.presets.iter().filter(|p| p.builtin).count();
let _ = s.presets.len();
assert_eq!(s.presets.generation(), g0, "reads keep the generation");
s.presets.push(many(1).remove(0));
let g1 = s.presets.generation();
assert_ne!(g1, g0);
for p in &mut s.presets {
p.favorite = false;
}
assert_ne!(s.presets.generation(), g1);
// generations are unique across sessions (UI caches key on them alone)
let other = Session::new();
assert_ne!(other.presets.generation(), s.presets.generation());
}
#[test]
fn importing_thousands_of_presets_is_not_quadratic() {
let mut s = Session::new();
let t = Instant::now();
let added = s.add_presets(many(20_000));
assert_eq!(added.len(), 20_000);
// the same presets again are all skipped as duplicates
assert!(s.add_presets(many(20_000)).is_empty());
// a clashing id with different settings gets a fresh, unique one
let mut clash = many(1).remove(0);
clash.settings = json!({"light": {"exposure": 3.0}});
let id = s.add_presets(vec![clash]).remove(0);
assert_ne!(id, "user.coll-0.look-0");
assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1);
// quadratic was ~200 M comparisons here (tens of seconds in debug builds)
assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed());
}
/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test
/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied
/// into a temporary library; the original is only read.
#[test]
#[ignore]
fn presets_scale_bench() {
let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else {
eprintln!("set LIGHTCRAFT_BENCH_PRESETS");
return;
};
let lib = temp_dir("bench");
std::fs::copy(&src, lib.join("presets.json")).unwrap();
let mut s = Session::new();
let t = Instant::now();
s.open_library(&lib, true).unwrap();
eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len());
let t = Instant::now();
for i in 0..20 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
}
eprintln!("develop.set: {:?} per command", t.elapsed() / 20);
let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap();
let t = Instant::now();
s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap();
eprintln!("preset.apply: {:?}", t.elapsed());
let t = Instant::now();
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed());
let _ = std::fs::remove_dir_all(&lib);
}
+58 -4
View File
@@ -441,23 +441,77 @@ fn a_scan_stays_current_while_only_other_folders_change() {
assert_eq!(r["imported"], 1, "{r}");
}
/// Work on the folder's photos while it is scanned (a rating, keywords, an edit) changes nothing
/// about what the scan found: a scan of a big folder on a slow disk takes minutes, and the
/// dialog says to go on working. Synchronize does what it showed.
#[test]
fn a_change_to_the_folders_photos_makes_the_scan_stale() {
fn working_on_the_folders_photos_meanwhile_leaves_the_scan_current() {
let dir = Scratch::new("worked-on");
let mut s = library(&dir);
write_png(&dir.path("trip/c.png"), 3);
scan(&mut s, &dir.path("trip"));
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
s.selection = crate::Selection::single(a);
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
s.execute("photo.setMeta", &json!({"ids": [a.0], "addKeywords": ["Trips|Alps", "snow"], "title": "A"})).unwrap();
s.execute("photo.label", &json!({"ids": [a.0], "label": "red"})).unwrap();
s.execute("photo.pick", &json!({"ids": [a.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
// into several albums, a virtual copy made of it, and one of its edits undone
s.execute("photo.virtualCopy", &json!({"ids": [a.0]})).unwrap();
s.selection = crate::Selection::single(a);
for name in ["One", "Two"] {
let album = s.execute("album.create", &json!({"name": name})).unwrap()["id"].as_u64().unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": [a.0]})).unwrap();
}
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
assert_eq!(r["imported"], 1, "what the scan showed is done: {r}");
assert_eq!(s.catalog.photo(a).unwrap().rating, 2, "and the work stays");
}
/// What the scan found is no longer true once the folder holds other photos, or one of them is
/// another file now: then it is refused, never redone on the spot.
#[test]
fn a_change_to_which_photos_the_folder_holds_makes_the_scan_stale() {
let dir = Scratch::new("stale-folder");
let mut s = library(&dir);
write_png(&dir.path("trip/c.png"), 3);
scan(&mut s, &dir.path("trip"));
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
// the dialog asks for exactly what it showed: refused, never redone on the spot
s.selection = crate::Selection::single(a);
// one of its photos goes to Recently Deleted
s.execute("photo.delete", &json!({"ids": [a.0]})).unwrap();
let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string();
assert!(e.contains("scan again"), "{e}");
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done");
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 1, "nothing was done");
// an agent that just asks to synchronize gets a fresh scan
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
assert_eq!(r["imported"], 1, "{r}");
}
/// A photo whose sidecar the scan found news in, changed here since: what the sidecar says would
/// go over that work, which the person was not shown. That scan is refused.
#[test]
fn a_change_to_a_photo_whose_sidecar_has_news_makes_the_scan_stale() {
let dir = Scratch::new("stale-sidecar");
let mut s = library(&dir);
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
let found = scan(&mut s, &dir.path("trip"));
assert_eq!(found["metadata"].as_array().map(Vec::len), Some(1), "{found}");
// work on another photo of the folder does not matter…
let (a, b) = (s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id, s.catalog.photos().find(|p| p.file_name == "b.png").unwrap().id);
s.selection = crate::Selection::single(b);
s.execute("photo.rate", &json!({"ids": [b.0], "rating": 1})).unwrap();
assert!(s.folder_changes.is_some());
// …work on the photo whose sidecar is to be read does
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true, "readMetadata": true})).unwrap_err().to_string();
assert!(e.contains("scan again"), "{e}");
assert_eq!(s.catalog.photo(a).unwrap().rating, 2, "nothing was read over it");
}
#[test]
fn the_progress_counts_the_photos_checked_too() {
use std::sync::atomic::Ordering::Relaxed;
File diff suppressed because it is too large. Load diff
+13 -5
View File
@@ -285,9 +285,11 @@ fn default_lightroom_profiles_are_not_reported() {
let _ = std::fs::remove_dir_all(&src);
}
/// Foreign profile names and saved Upright matrices stay visible without pretending their
/// proprietary colour or coordinate data was imported. Diagnostics are per-photo report data;
/// importing a file without XMP remains quiet, and the source packet is never rewritten.
/// Foreign profile names and saved Upright matrices that can't be applied stay visible without
/// pretending their proprietary colour or coordinate data was imported (this packet's Upright
/// version is not the measured one: `tests_upright` covers the matrices that are imported).
/// Diagnostics are per-photo report data; importing a file without XMP remains quiet, and the
/// source packet is never rewritten.
#[test]
fn foreign_profile_and_upright_import_diagnostics_are_actionable() {
let src = temp_dir("xmp-diagnostics");
@@ -296,7 +298,7 @@ fn foreign_profile_and_upright_import_diagnostics_are_actionable() {
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:CameraProfile="Camera Faithful" crs:Exposure2012="1.68" crs:PerspectiveUpright="1"
crs:UprightVersion="151388160" crs:UprightTransformCount="6"
crs:UprightVersion="151388161" crs:UprightTransformCount="6"
crs:UprightCenterNormX="0.554156" crs:UprightCenterNormY="0.529745"
crs:UprightTransform_1="0.897233402,-0.013204641,-0.030578613,-0.096087436,0.953621255,0.003270197,-0.157075841,-0.043520282,1.000000000"/>
</rdf:RDF></x:xmpmeta>"#;
@@ -348,12 +350,18 @@ fn foreign_profile_and_upright_import_diagnostics_are_actionable() {
"{reread}"
);
assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == "auto"), "{reread}");
for (source, mode) in [("0", "off"), ("2", "level"), ("3", "vertical"), ("4", "full"), ("5", "guided"), ("9", "unknown")] {
// Lightroom's values: 2 Full, 3 Level, 4 Vertical (issue #205)
for (source, mode) in [("2", "full"), ("3", "level"), ("4", "vertical"), ("5", "guided"), ("9", "unknown")] {
let mode_xmp = xmp.replace("crs:PerspectiveUpright=\"1\"", &format!("crs:PerspectiveUpright=\"{source}\""));
std::fs::write(&sidecar, mode_xmp).unwrap();
let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap();
assert!(reread["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "uprightGeometry" && w["mode"] == mode), "{source}: {reread}");
}
// with Upright Off Lightroom applies none of its saved matrices: nothing to warn about
std::fs::write(&sidecar, xmp.replace("crs:PerspectiveUpright=\"1\"", "crs:PerspectiveUpright=\"0\"")).unwrap();
let reread = s.execute("photo.readMetadataFromFile", &json!({"ids": [p.id.0]})).unwrap();
assert!(reread["warnings"].as_array().into_iter().flatten().all(|w| w["code"] != "uprightGeometry"), "{reread}");
std::fs::write(&sidecar, xmp).unwrap();
// A foreign name colliding with a built-in profile label remains warning metadata only.
let collision = src.join("collision.png");
+174
View File
@@ -0,0 +1,174 @@
//! An Upright correction (`Geometry::upright_transform`) is one photo's own analysis result: the
//! correction found for that image (by LightCraft, or Lightroom's saved one for it), stored so
//! previews and exports agree, in the frame the photo is shown in. When settings move or change
//! (issue #205):
//!
//! - between entries of the same file (a photo and its virtual copies) the correction moves too,
//! turned by the difference in their Rotate Left / Right;
//! - between different files the rest of the Geometry group moves (the Upright mode, the Transform
//! sliders, Guided guides, Constrain Crop), not the correction: the target keeps its own while
//! its mode stays the same, else solves its own (a correction found for one scene is wrong for
//! another);
//! - presets never carry a correction (they are for other photos);
//! - a change that turns a photo turns the correction it keeps, and a change of mode drops it to
//! be solved again;
//! - settings JSON (`develop.merge`) that states the photo's own correction, in whatever frame it
//! was read and with its numbers rounded, names that correction, which is kept as above; any
//! other correction it states is taken as given.
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_geom::{Homography, Orientation};
use serde_json::Value;
/// The photo settings were taken from, as far as its Upright correction is concerned.
#[derive(Clone, Debug)]
pub struct UprightSource {
source: Source,
master: PhotoId,
/// Its Rotate Left / Right when the settings were taken.
user: Orientation,
/// The library it was in (photo ids start again in each library).
library: std::sync::Weak<()>,
}
impl UprightSource {
/// Whether it was taken in the library `identity` is the identity of
/// (`Session::library_identity`). Photo ids belong to one library, so a source from another
/// one can't be matched to a photo by id.
pub fn taken_in(&self, identity: &std::sync::Arc<()>) -> bool {
std::sync::Weak::ptr_eq(&self.library, &std::sync::Arc::downgrade(identity))
}
}
/// Photo `p` with settings `s`, in the library whose identity is `library`, as the source of a
/// transfer.
pub fn source_of(p: &Photo, s: &DevelopSettings, library: &std::sync::Arc<()>) -> UprightSource {
UprightSource { source: p.source.clone(), master: p.copy_of.unwrap_or(p.id), user: s.orientation, library: std::sync::Arc::downgrade(library) }
}
/// Whether photo `p` (in the library `from` was taken in) shows the same file as `from`: the photo
/// itself, its original or another of its virtual copies, or another entry of the file.
fn same_file(from: &UprightSource, p: &Photo) -> bool {
from.master == p.copy_of.unwrap_or(p.id) || from.source == p.source
}
/// The turn that takes a photo shown with `from` to `to` (both after the file's own orientation).
fn turn(from: Orientation, to: Orientation) -> Orientation {
from.inverse().then(to)
}
/// Settings `after` of photo `p` (which had `before`) once `patch` was applied, with its Upright
/// correction settled as the module describes. `source`: the photo the patch was taken from, in
/// this photo's library (`None`: none, as for a preset).
pub fn settle(p: &Photo, before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value, source: Option<&UprightSource>) {
if patch.pointer("/geometry/upright_transform").is_none() {
return settle_edit(before, after);
}
let g = &mut after.geometry;
g.upright_transform = match source.filter(|from| same_file(from, p)) {
// the same file's correction, in this entry's rotation
Some(from) => g.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn(from.user, after.orientation))),
// another file's (or a preset's): not this photo's; it keeps its own while the mode stays
None if g.upright == before.geometry.upright => {
before.geometry.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn(before.orientation, after.orientation)))
}
None => None,
};
}
/// A change of a photo's own settings (`before` to `after`) that doesn't state a correction: one
/// that turns the photo turns the correction it keeps; one that changes the Upright mode drops it
/// to be solved again.
pub fn settle_edit(before: &DevelopSettings, after: &mut DevelopSettings) {
let g = &mut after.geometry;
if g.upright != before.geometry.upright {
g.upright_transform = None;
} else if after.orientation != before.orientation {
g.upright_transform = g.upright_transform.map(|h| lightcraft_pipeline::upright::turned(&h, turn(before.orientation, after.orientation)));
}
}
/// Settings `after` of a photo (which had `before`) once settings JSON `patch` was merged into them
/// (`develop.merge`), with its Upright correction settled. A correction the JSON states that is the
/// photo's own ([`names_own`]) counts as not stated: the photo keeps its correction, turned to the
/// stated orientation, or drops it for a new mode ([`settle_edit`]). Settings sent back whole carry
/// the correction as they were read: before a turn, or before an earlier send turned it, and maybe
/// with the numbers rewritten. Any other stated correction is taken as given.
pub fn settle_json(before: &DevelopSettings, after: &mut DevelopSettings, patch: &Value) {
if let Some(v) = patch.pointer("/geometry/upright_transform") {
let stated = serde_json::from_value::<Option<Homography>>(v.clone()).ok();
if !stated.is_some_and(|h| names_own(h, before.geometry.upright_transform)) {
return;
}
after.geometry.upright_transform = before.geometry.upright_transform;
}
settle_edit(before, after);
}
/// The eight frames a photo's correction can be read in (any Rotate Left / Right and flip).
const FRAMES: [Orientation; 8] = [
Orientation::Normal,
Orientation::FlipH,
Orientation::Rotate180,
Orientation::FlipV,
Orientation::Transpose,
Orientation::Rotate90,
Orientation::Transverse,
Orientation::Rotate270,
];
/// Whether correction `stated` is the photo's own correction `own`, read in any of its frames
/// (`upright::turned` only swaps and negates elements, so each frame's copy is exact), up to
/// [`SAME_CORRECTION`]. Whichever frames match, keeping `own` turned to the stated orientation gives
/// the same result.
fn names_own(stated: Option<Homography>, own: Option<Homography>) -> bool {
match (stated, own) {
(None, None) => true,
(Some(s), Some(o)) => FRAMES.iter().any(|f| same_correction(&s, &lightcraft_pipeline::upright::turned(&o, *f))),
_ => false,
}
}
/// How far apart two corrections may be, per element with each scaled so its last element is 1,
/// and still be the same correction.
///
/// - A program that writes the numbers its own way may round them. At 6 decimals that moves an
/// element by at most 5e-7, about twice that after the scaling, and at 9 or 12 by far less: the
/// limit is ten times the 6-decimal error.
/// - Corrections this close make the same picture. Moving each element of a typical correction by
/// 1e-5 moves no corner of the photo by more than about 6e-5 of half its long edge: under 0.2 px at
/// 6000 px. So a correction stated on purpose and found this close to the photo's own (two
/// photos' Level corrections can be) loses at most that much by being taken as the photo's own,
/// and anything further apart is taken as given.
const SAME_CORRECTION: f64 = 1e-5;
/// Whether corrections `a` and `b` are the same up to [`SAME_CORRECTION`] (never for a matrix that
/// can't be scaled: a last element of 0 or not finite).
fn same_correction(a: &Homography, b: &Homography) -> bool {
let ([.., sa], [.., sb]) = (a.0, b.0);
sa.is_finite() && sb.is_finite() && sa != 0.0 && sb != 0.0 && a.0.iter().zip(b.0.iter()).all(|(x, y)| (x / sa - y / sb).abs() <= SAME_CORRECTION)
}
/// `patch` applied to settings `before` of photo `p`, taken from `source` ([`settle`]).
pub fn transferred(p: &Photo, before: &DevelopSettings, patch: &Value, source: Option<&UprightSource>) -> DevelopSettings {
let mut after = lightcraft_develop::apply_partial(before, patch, 1.0);
settle(p, before, &mut after, patch, source);
after
}
/// `preset` at `amount` applied to settings `before` of photo `p`, as Apply Preset stores it: a
/// preset carries no Upright correction ([`settle`] with no source photo). Previews use it too.
pub fn apply_preset(p: &Photo, before: &DevelopSettings, preset: &lightcraft_develop::Preset, amount: f64) -> DevelopSettings {
let mut after = preset.apply(before, amount);
settle(p, before, &mut after, &preset.settings, None);
after
}
/// Preset settings without a saved Upright correction (the mode and the sliders stay).
pub fn without_correction(mut settings: Value) -> Value {
if let Some(g) = settings.get_mut("geometry").and_then(Value::as_object_mut) {
g.remove("upright_transform");
}
settings
}
+27
View File
@@ -342,6 +342,15 @@ impl Orientation {
// flipH ∘ rot(t) = rot(-t) ∘ flipH
Orientation::from_parts(!f, (4 - t) % 4)
}
/// This orientation, then `next` applied to its result (`a.then(b).map(p) == b.map(a.map(p))`,
/// each with its own image size): e.g. a file's EXIF orientation, then the user's Rotate Right.
pub fn then(self, next: Orientation) -> Orientation {
let (f1, t1) = self.to_parts();
let (f2, t2) = next.to_parts();
// a flip reverses the turns before it: F·R^t = R^-t·F
let turns = if f2 { (4 + t2 - t1 % 4) % 4 } else { (t1 + t2) % 4 };
Orientation::from_parts(f1 != f2, turns)
}
/// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns
/// are their own inverses; quarter turns swap.
pub fn inverse(self) -> Orientation {
@@ -444,6 +453,24 @@ mod tests {
assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90);
}
#[test]
fn orientations_compose() {
let (w, h) = (4.0, 2.0);
for a in 1..=8 {
for b in 1..=8 {
let (a, b) = (Orientation::from_exif(a), Orientation::from_exif(b));
let ab = a.then(b);
let (aw, ah) = if a.swaps_axes() { (h, w) } else { (w, h) };
for (x, y) in [(0.0, 0.0), (1.0, 0.5), (3.5, 1.5), (4.0, 2.0)] {
let (u, v) = a.map(x, y, w, h);
assert_eq!(b.map(u, v, aw, ah), ab.map(x, y, w, h), "{a:?} then {b:?}");
}
}
}
assert_eq!(Orientation::Rotate270.then(Orientation::Rotate90), Orientation::Normal);
assert_eq!(Orientation::Rotate90.then(Orientation::Normal.flipped_h()), Orientation::Rotate90.flipped_h());
}
#[test]
fn orientation_inverse_and_norm_rect() {
let r = Rect::new(0.1, 0.2, 0.3, 0.6);
+32 -3
View File
@@ -32,7 +32,7 @@ const MODULES: &[Module] = &[
Module {
src: include_str!("wgsl/blur.wgsl"),
bindings: &[("src", false, "f32"), ("dst", true, "f32")],
entries: &["box_h", "box_v", "median_h", "median_v"],
entries: &["box_h", "box_v", "median_h", "median_v", "min_h", "min_v"],
},
Module {
src: include_str!("wgsl/resize.wgsl"),
@@ -281,7 +281,8 @@ fn module_source(m: &Module, consts: &str) -> String {
}
impl Gpu {
/// Create a device on the best available adapter (no software fallback), or why there is none.
/// Create a device on the best available adapter (no software adapter unless
/// `LIGHTCRAFT_GPU_ALLOW_SOFTWARE`), or why there is none.
pub fn new(backends: wgpu::Backends) -> Result<Gpu, String> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
// only these drivers are loaded (`crate::backend`: never Vulkan on Windows unless asked)
@@ -296,10 +297,13 @@ impl Gpu {
)
.map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?;
let info = adapter.get_info();
if info.device_type == wgpu::DeviceType::Cpu {
if refuse_adapter(info.device_type, software_allowed()) {
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
return Err(format!("software adapter ({}) skipped: the CPU pipeline is faster", info.name));
}
if info.device_type == wgpu::DeviceType::Cpu {
log::info!("gpu: LIGHTCRAFT_GPU_ALLOW_SOFTWARE: using the software adapter {}", info.name);
}
let mut limits = adapter.limits();
if limits.max_storage_buffers_per_shader_stage < 10 {
log::info!("gpu: {} has too few storage buffers per stage", info.name);
@@ -584,6 +588,20 @@ impl Gpu {
/// normally reset a hung GPU much sooner).
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Whether to turn down an adapter of `device_type`: a software rasterizer (llvmpipe / lavapipe,
/// WARP) is slower than the CPU pipeline, so it is refused unless `allow_software`.
fn refuse_adapter(device_type: wgpu::DeviceType, allow_software: bool) -> bool {
device_type == wgpu::DeviceType::Cpu && !allow_software
}
/// `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1`: accept a software adapter. For tests on machines without a
/// GPU (CI runners: Mesa lavapipe on Linux, WARP on Windows), so the CPU ↔ GPU equivalence tests
/// run there instead of skipping; the app never sets it (software rendering is slower than the CPU
/// pipeline).
fn software_allowed() -> bool {
std::env::var("LIGHTCRAFT_GPU_ALLOW_SOFTWARE").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
/// `LIGHTCRAFT_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter.
/// `webgpu` / `downlevel` = 128 MiB storage bindings and 256 MiB buffers (the WebGPU defaults),
/// `<n>` = n MiB storage bindings and 2n MiB buffers. Returns (binding bytes, buffer bytes).
@@ -734,6 +752,17 @@ mod tests {
assert_eq!(super::parse_limits("lots"), None);
}
#[test]
fn software_adapters_need_the_opt_in() {
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other, VirtualGpu};
assert!(super::refuse_adapter(Cpu, false), "llvmpipe / WARP are refused by default");
assert!(!super::refuse_adapter(Cpu, true), "LIGHTCRAFT_GPU_ALLOW_SOFTWARE accepts them");
for t in [DiscreteGpu, IntegratedGpu, VirtualGpu, Other] {
assert!(!super::refuse_adapter(t, false), "{t:?}");
assert!(!super::refuse_adapter(t, true), "{t:?}");
}
}
#[test]
fn dispatches_stay_within_the_workgroup_limit() {
// 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis
+2
View File
@@ -26,6 +26,7 @@ const FIELDS: &[(&str, usize)] = &[
("CLAR", 1),
("TEX", 1),
("DEHAZE", 1),
("DEHAZE_V3", 1),
("SHARPEN", 1),
("SHARPEN_MASK", 1),
("HAS_CLAR", 1),
@@ -175,6 +176,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.f("CLAR", fp.clar);
p.f("TEX", fp.tex);
p.f("DEHAZE", fp.dehaze);
p.b("DEHAZE_V3", fp.dehaze_v3);
p.f("SHARPEN", fp.sharpen);
p.f("SHARPEN_MASK", fp.sharpen_mask);
p.b("HAS_CLAR", present.clarity);
+57 -9
View File
@@ -9,6 +9,7 @@ use lightcraft_geom::Orientation;
use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms};
use lightcraft_pipeline::geometry::SampleMode;
use lightcraft_pipeline::local::plane_at;
use lightcraft_pipeline::masks::{AlphaCache, AlphaKeys, alpha_key};
use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local};
use lightcraft_raster::resample::Filter;
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8};
@@ -34,6 +35,9 @@ struct Entry {
sampled: Arc<Buf>,
lin: Option<(u64, Arc<Buf>)>,
planes: Planes,
/// Alphas of the last render's masks, as [`lightcraft_pipeline::masks::AlphaCache`] keeps them
/// on the CPU (same keys).
masks: AlphaCache<Buf>,
}
/// Spatial planes of one linear image (`key` = its `lin_key`), each tagged with its radius.
@@ -44,7 +48,7 @@ struct Planes {
base: Option<(local::BaseKey, Arc<Buf>)>,
clarity: Option<(u32, Arc<Buf>)>,
texture: Option<(u32, Arc<Buf>)>,
dark: Option<(u32, Arc<Buf>, f32)>,
dark: Option<(u64, Arc<Buf>, f32)>,
chroma: Option<(u32, Arc<Buf>)>,
}
@@ -106,6 +110,8 @@ impl GpuStages {
p.clarity.iter().for_each(|(_, b)| add(b));
p.texture.iter().for_each(|(_, b)| add(b));
p.dark.iter().for_each(|(_, b, _)| add(b));
p.chroma.iter().for_each(|(_, b)| add(b));
e.masks.planes().for_each(&mut add);
}
if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) {
add(b);
@@ -326,6 +332,19 @@ fn median_hv(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, r: usize) -> Buf {
out
}
/// Separable minimum of a plane (`local::min_hv`).
fn min_hv(cx: &mut Cx<'_>, p: Buf, w: usize, h: usize, r: usize) -> Buf {
if r == 0 || w * h == 0 {
return p;
}
let pr = [w as u32, h as u32, 1, r.min(local::DEHAZE_PATCH_MAX) as u32];
let tmp = cx.gpu.buffer(w * h);
cx.run("min_h", &pr, &[Some(&p), Some(&tmp)], groups2(w, h, [16, 16]));
let out = cx.gpu.buffer(w * h);
cx.run("min_v", &pr, &[Some(&tmp), Some(&out)], groups2(w, h, [16, 16]));
out
}
/// The highlights/shadows base (`local::tone_base`; `detail` 0: V1, no median).
fn tone_base(cx: &mut Cx<'_>, l: &Buf, w: usize, h: usize, sigma: f32, detail: usize) -> Buf {
if detail == 0 {
@@ -644,6 +663,7 @@ pub fn render(
lap("wb/nr", &mut t, &mut cx);
// 3. spatial planes
let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default();
let mut planes = match cached.map(|e| e.planes) {
Some(p) if p.key == plan.lin_key => p,
_ => Planes { key: plan.lin_key, ..Default::default() },
@@ -654,13 +674,13 @@ pub fn render(
prep.air = a;
}
lap("planes", &mut t, &mut cx);
if let Some(c) = stages {
c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes });
}
// 4. masks
let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host);
let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host, &mut alphas);
lap("masks", &mut t, &mut cx);
if let Some(c) = stages {
c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes, masks: alphas });
}
// 5. per-pixel stage
let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
@@ -747,6 +767,14 @@ pub fn render(
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
}
});
// a sharpening view: the detail planes this render sharpened with
let overlay_mask = overlay_mask.or_else(|| {
req.overlay.reads_detail().then(|| {
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
let tex = prep.texture.as_deref().map(|b| cx.read_plane(b, w, h));
lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, tex.as_ref(), plan.px_per_long)
})?
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
@@ -856,10 +884,11 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
let (dark, air) = match sig.dark {
None => (None, 1.0),
Some(sg) => match &planes.dark {
Some((k, d, air)) if *k == sg.to_bits() => (Some(d.clone()), *air),
Some((k, d, air)) if *k == local::dark_key(sg, sig.dark_patch) => (Some(d.clone()), *air),
_ => {
let d0 = cx.gpu.buffer(n);
map(cx, "dark_k", n, &[], [Some(lin), None, None], &d0);
let d0 = min_hv(cx, d0, w, h, sig.dark_patch);
let d = gaussian(cx, &d0, w, h, 1, sg);
// airlight: a percentile of every 7th value, as on the CPU
let m = n.div_ceil(local::AIRLIGHT_STEP);
@@ -868,7 +897,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
let air = local::airlight_of(cx.read(&sub, m));
cx.flush();
let b = Arc::new(d);
planes.dark = Some((sg.to_bits(), b.clone(), air));
planes.dark = Some((local::dark_key(sg, sig.dark_patch), b.clone(), air));
(Some(b), air)
}
},
@@ -924,14 +953,25 @@ fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, s
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated,
/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU. A mask whose alpha `alphas`
/// holds (same [`alpha_key`]) is copied from there; `alphas` keeps this render's.
fn masks(
cx: &mut Cx<'_>,
lin: &Buf,
prep: &Prep,
plan: &Plan<'_>,
host: &mut Host,
alphas: &mut AlphaCache<Buf>,
) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
use lightcraft_develop::{MaskOp, MaskShape};
let s = &*plan.settings;
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
let old = alphas.take(plan.mattes.as_ref());
if list.is_empty() {
return (None, Vec::new());
}
let keys = AlphaKeys { geo: plan.geo, image: plan.lin_key };
let mut used = Vec::with_capacity(list.len());
let (w, h) = (plan.w, plan.h);
let n = w * h;
let frame = &plan.frame;
@@ -946,6 +986,12 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
let comp_plane = cx.gpu.buffer(n);
for (mi, m) in list.iter().enumerate() {
let key = alpha_key(m, keys, ev);
if let Some((_, b)) = old.iter().find(|(k, _)| *k == key) {
cx.copy_into(b, &alpha, mi * n);
used.push((key, b.clone()));
continue;
}
let mut first = true;
for comp in &m.components {
let op = match comp.op {
@@ -1055,7 +1101,9 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
&[None, None, None, Some(&comp_plane), Some(&alpha)],
groups1(n),
);
used.push((key, Arc::new(cx.slice(&alpha, mi * n, n))));
}
alphas.keep(plan.mattes.as_ref(), used);
let terms = list.iter().map(|m| mask_terms(&m.adjust)).collect();
(Some(alpha), terms)
}
+37
View File
@@ -133,3 +133,40 @@ fn median_v(@builtin(global_invocation_id) g: vec3<u32>) {
}
dst[g.y * w + g.x] = median_of(n, r, &v);
}
// Separable minimum of a single-channel plane (`lightcraft_pipeline::local::min_hv`): 2r+1 taps
// (clamped to the image) along a row (min_h) or a column (min_v). P: w, h, nc (unused), r.
@compute @workgroup_size(16, 16)
fn min_h(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let r = pu(3u);
if (g.x >= w || g.y >= h) {
return;
}
let row = g.y * w;
let a = select(0u, g.x - r, g.x >= r);
let b = min(g.x + r, w - 1u);
var m = src[row + a];
for (var x = a + 1u; x <= b; x++) {
m = min(m, src[row + x]);
}
dst[row + g.x] = m;
}
@compute @workgroup_size(16, 16)
fn min_v(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let r = pu(3u);
if (g.x >= w || g.y >= h) {
return;
}
let a = select(0u, g.y - r, g.y >= r);
let b = min(g.y + r, h - 1u);
var m = src[a * w + g.x];
for (var y = a + 1u; y <= b; y++) {
m = min(m, src[y * w + g.x]);
}
dst[g.y * w + g.x] = m;
}
+8 -1
View File
@@ -326,7 +326,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (dz != 0.0 && pu(F_HAS_DARK) != 0u) {
let d = clamp(dark[i] / pf(F_AIR_PRE), 0.0, 1.0);
let air = pf(F_AIR);
if (dz > 0.0) {
if (dz > 0.0 && pu(F_DEHAZE_V3) != 0u) {
// process V3 (`finish::dehaze_v3_pixel`)
let k0 = clamp(dz, 0.0, 1.0);
let k = k0 * (0.4 + 0.6 * k0);
let t = max(1.0 - 0.95 * k * d, 0.15);
let veil = min(air * (1.0 - t), 0.9 * max(min(c.x, min(c.y, c.z)), 0.0));
c = max((c - veil) / t, vec3<f32>(0.0));
} else if (dz > 0.0) {
let t = max(1.0 - 0.95 * min(dz, 1.0) * d, 0.12);
c = max((c - air * (1.0 - t)) / t, vec3<f32>(0.0));
} else {
+108 -11
View File
@@ -2,7 +2,8 @@
//! `lightcraft_gpu::render`, compared in 8-bit sRGB.
//!
//! Bounds (documented in `docs/gpu-pipeline.md`): mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB per
//! channel for every case. Skips (passes with a note) when no GPU adapter exists, e.g. on CI.
//! channel for every case. Skips (passes with a note) when no GPU adapter exists; CI runs them on
//! software adapters with `LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` (lavapipe, WARP) and on Metal.
use std::sync::Arc;
@@ -553,6 +554,67 @@ fn cached_renders_match_uncached() {
}
}
/// Mask alphas kept on the device across a slider drag: a warm render equals a fresh one whatever
/// the edit (one each mask reads, and ones none of them read), the CPU's result within the bounds,
/// and the alphas count in the stages' device bytes.
#[test]
fn cached_renders_with_masks_match_uncached() {
if !gpu() {
return;
}
let src = scene(1, 900, 600);
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let req = RenderRequest::fit(640, 640);
let comp = |shape: MaskShape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape };
let mask = |id: u32, shape: MaskShape| Mask { id, components: vec![comp(shape)], ..Default::default() };
let mut s = DevelopSettings::default();
typical(&mut s);
let stroke = BrushStroke { points: (0..10).map(|k| Point::new(0.1 + 0.07 * k as f64, 0.6)).collect(), size: 0.04, ..Default::default() };
s.masks = vec![
mask(1, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false }),
mask(2, MaskShape::Brush { strokes: vec![stroke] }),
mask(3, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 }),
// no kernel: evaluated on the CPU and uploaded
mask(4, MaskShape::Subject),
];
for (i, m) in s.masks.iter_mut().enumerate() {
m.adjust.exposure = 0.3 * i as f64 - 0.4;
m.adjust.clarity = 20.0;
}
let steps: Vec<Box<dyn Fn(&mut DevelopSettings)>> = vec![
Box::new(|_| {}),
Box::new(|s| s.light.exposure = 0.8),
Box::new(|s| s.masks[0].adjust.exposure = 1.0),
Box::new(|s| s.masks[3].adjust.amount = 50.0),
Box::new(|s| {
s.masks[0].components[0].shape =
MaskShape::Radial { center: Point::new(0.6, 0.4), rx: 0.2, ry: 0.2, angle: 0.0, feather: 30.0, invert: false }
}),
Box::new(|s| s.masks.swap(1, 2)),
Box::new(|s| {
s.wb.mode = WbMode::Custom;
s.wb.temp = 4300.0;
}),
Box::new(|s| s.masks[1].visible = false),
];
for (i, step) in steps.iter().enumerate() {
step(&mut s);
let warm = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu");
let fresh = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu");
assert_eq!(warm.image, fresh.image, "step {i}");
}
check("masks after a cached drag", &src, &info, &s, &req);
// three visible masks of 4 B/px held on the device besides the stages
let out = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu").image;
let n = out.width * out.height;
let with = lightcraft_gpu::stage_bytes(&cache);
s.masks.clear();
let fresh = StageCache::default();
lightcraft_gpu::render(&src, &info, &s, &req, Some(&fresh)).expect("gpu");
assert!(with >= lightcraft_gpu::stage_bytes(&fresh) + 3 * 4 * n, "{with}");
}
/// Issue #632: the Highlights / Shadows base of each process (V1: the edge-aware filter of log
/// luminance; V2: of its fine median, a radius that grows with the render) matches the CPU's, and
/// a warm cache never hands one process the other's base.
@@ -660,6 +722,34 @@ fn overlays_match() {
let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) };
check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req);
}
// Alt-drag previews: clipping and greyscale on the finished image; the sharpening views from
// each renderer's own detail planes (with sharpening on, and off: no texture plane then).
let mut s = DevelopSettings::default();
(s.light.whites, s.light.blacks, s.detail.sharpen_masking) = (80.0, -80.0, 40.0);
for amount in [50.0, 0.0] {
s.detail.sharpen_amount = amount;
for o in [Overlay::ClipHighlights, Overlay::ClipShadows, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask] {
let req = RenderRequest { overlay: o, ..RenderRequest::fit(640, 640) };
if matches!(o, Overlay::ClipHighlights | Overlay::ClipShadows) {
// a binary view: a 1-LSB difference at 0 / 255 flips a channel, so compare shares
let cpu = render(&src, &raw, &s, &req).image;
let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image;
let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64;
assert!(differ < 0.002, "{o:?} sharpen {amount}: {differ}");
} else if o == Overlay::Grayscale {
check(&format!("{o:?} preview"), &src, &raw, &s, &req);
} else {
// the sharpening views amplify the detail planes (the mask through a steep
// smoothstep): allow a few larger differences where the planes differ slightly
let cpu = render(&src, &raw, &s, &req).image;
let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image;
let (mean, max, _) = diff(&cpu, &gpu);
let far = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a[0].abs_diff(b[0]) > 8).count() as f64 / cpu.data.len() as f64;
eprintln!("{o:?} sharpen {amount:<4} mean {mean:.4} max {max} >8: {:.4}%", far * 100.0);
assert!(mean < 1.0 && far < 0.005, "{o:?} sharpen {amount}: mean {mean:.4}, >8 LSB {far}");
}
}
}
}
#[test]
@@ -800,13 +890,20 @@ fn windows_match() {
check("window: vignette corner", &src, &info, &s, &corner);
}
/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that
/// upload with the view's stages counted it against the stage budget on every tick of a drag and
/// got the stages cleared, and the upload and resampling redone: the window's stages are its own
/// pixels, not the photo's.
/// Issue #323: a window of a big photo is cut from the whole source on the device (288 MB for 24 MP).
/// Keeping that upload with the view's stages counted it against the stage budget on every tick of
/// a drag and got the stages cleared, and the upload and resampling redone: the window's stages are
/// its own pixels, not the photo's.
// (these two share the process's one big source: a test lock keeps them from replacing each other's)
static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The big photo of these tests: 10.24 MP, a 117 MiB source. That is over what a view keeps with its
/// stages (96 MiB, so it is shared) and within a 128 MiB storage binding (the WebGPU default, and
/// the limit of Mesa's lavapipe in CI), so the source goes to the device on every adapter. A source
/// over the device's limit is resampled on the CPU instead and never uploaded, which would leave
/// these tests nothing to check.
const BIG: usize = 3200;
#[test]
fn a_window_does_not_keep_the_whole_source_on_the_device() {
let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner());
@@ -814,11 +911,11 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let src = scene(1, BIG, BIG);
let info = SourceInfo { raw: true, ..Default::default() };
let stages = StageCache::default();
let s = DevelopSettings::default();
let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(BIG, BIG) };
let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render");
let held = lightcraft_gpu::stage_bytes(&stages);
assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20);
@@ -830,7 +927,7 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() {
assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20);
}
/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per
/// …but the source is uploaded once for every window of every view of the photo, not once per
/// window: panning at 1:1, and the Before and After windows, share one device copy of it, which
/// goes when the app idles (`trim_pool(0)`).
#[test]
@@ -840,17 +937,17 @@ fn windows_of_a_big_photo_share_one_uploaded_source() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let src = scene(1, BIG, BIG);
let info = SourceInfo { raw: true, ..Default::default() };
let s = DevelopSettings::default();
let (after, before) = (StageCache::default(), StageCache::default());
let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(BIG, BIG) };
let start = lightcraft_gpu::source_uploads();
for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] {
lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render");
}
assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views");
assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes());
assert!(lightcraft_gpu::shared_source_bytes() >= BIG * BIG * 12, "{}", lightcraft_gpu::shared_source_bytes());
assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages");
lightcraft_gpu::trim_pool(0);
assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back");
+2 -2
View File
@@ -11,11 +11,11 @@ license.workspace = true
# Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the
# other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL)
# code structure; gamut-heic has no HEVC decoder of its own).
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
rayon = { workspace = true }
[features]
+1 -1
View File
@@ -188,7 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
tool(
"set_develop",
"Set develop settings",
"Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop).",
"Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop). A change of `orientation` turns the photo's Upright correction with it, also when the settings are sent back whole (a copy read before a turn, or with its numbers rounded, still names the photo's own correction); a different `geometry.upright_transform` is taken as given, and a change of `geometry.upright` drops it to be solved again. To copy edits to other photos, run `develop.copy` and `develop.paste`, or `develop.sync`, with command_run: they keep each photo's own correction, while settings JSON taken from one photo carries that photo's.",
json!({
"id": photo_id,
"values": {"type": "object", "additionalProperties": {"type": "number"}, "description": "controlId → value"},
+88
View File
@@ -0,0 +1,88 @@
//! MCP `set_develop` with the settings `get_develop` returned, the rotation changed: the photo's
//! Upright correction is turned with it, as Rotate does (issue #205). The settings state the
//! unchanged correction; it used to be kept in the old frame.
use lightcraft_engine::Session;
use lightcraft_mcp::{Headless, call_tool};
use serde_json::{Value, json};
const AUTO: &str = "1.010477639,-0.008540717,-0.006124940,0.014938050,1.011645918,-0.015359712,-0.007190889,-0.003396748,1.000000000";
/// A 60 × 40 JPEG stored on its side (EXIF 6) with a Lightroom sidecar holding a saved Auto matrix,
/// in its own folder `name`.
fn fixture(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("lc-mcp-upright-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (w, h) = (60usize, 40usize);
let img = lightcraft_raster::Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i % w * 4) as u8, (i / w * 6) as u8, 91, 255]).collect() };
let exif = lightcraft_codecs::exif::minimal_exif(6);
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
let bytes =
lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, lightcraft_codecs::ChromaSubsampling::S444, &meta).unwrap();
std::fs::write(dir.join("a.jpg"), bytes).unwrap();
let xmp = format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PerspectiveUpright="1" crs:UprightVersion="151388160" crs:UprightTransformCount="6" crs:UprightTransform_1="{AUTO}"/></rdf:RDF></x:xmpmeta>"#
);
std::fs::write(dir.join("a.xmp"), xmp).unwrap();
dir.join("a.jpg")
}
fn correction(b: &Headless) -> Value {
let p = b.session.catalog.photos().next().unwrap();
serde_json::to_value(p.develop.geometry.upright_transform).unwrap()
}
#[test]
fn set_develop_with_get_develop_settings_turns_the_correction() {
let path = fixture("once");
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [path]})).unwrap();
let id = s.catalog.photos().next().unwrap().id.0;
s.execute("library.select", &json!({"ids": [id]})).unwrap();
let mut b = Headless::new(s);
let original = correction(&b);
assert!(!original.is_null());
assert!(!call_tool(&mut b, "command_run", &json!({"id": "photo.rotateRight", "params": {}})).is_error);
assert_ne!(correction(&b), original);
let got = call_tool(&mut b, "get_develop", &json!({"id": id}));
let mut whole = got.structured.clone().unwrap();
whole["orientation"] = json!("Normal");
let r = call_tool(&mut b, "set_develop", &json!({"id": id, "settings": whole}));
assert!(!r.is_error, "{:?}", r.structured);
assert_eq!(correction(&b), original);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// An agent's local copy of `get_develop`, read once and sent back whole after each change (the
/// rotation, then Exposure, then the same copy with its matrix written to 9 decimals): the
/// correction stays the photo's own in its frame (the second send used to put it back in the old
/// one).
#[test]
fn set_develop_of_a_local_copy_after_each_change_keeps_the_correction() {
let path = fixture("local");
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [path]})).unwrap();
let id = s.catalog.photos().next().unwrap().id.0;
s.execute("library.select", &json!({"ids": [id]})).unwrap();
let mut b = Headless::new(s);
let original = correction(&b);
assert!(!call_tool(&mut b, "command_run", &json!({"id": "photo.rotateRight", "params": {}})).is_error);
let mut local = call_tool(&mut b, "get_develop", &json!({"id": id})).structured.unwrap();
local["orientation"] = json!("Normal");
for change in ["rotation", "exposure", "9 decimals"] {
match change {
"exposure" => local["light"]["exposure"] = json!(0.5),
"9 decimals" => {
for x in local["geometry"]["upright_transform"].as_array_mut().unwrap() {
*x = json!((x.as_f64().unwrap() * 1e9).round() / 1e9);
}
}
_ => {}
}
let r = call_tool(&mut b, "set_develop", &json!({"id": id, "settings": local}));
assert!(!r.is_error, "{:?}", r.structured);
assert_eq!(correction(&b), original, "after the {change} send");
}
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
+10 -3
View File
@@ -8,11 +8,12 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr
|---|---|
| `read_exif(&[u8]) -> Metadata` | TIFF-structured Exif block (whole TIFF/DNG/raw file, or JPEG APP1 payload with/without `Exif\0\0`). `try_read_exif` reports header errors. |
| `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `lightcraft-raw`). |
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. |
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF / Fujifilm RAF / Canon CR3 / HEIF (HEIC, AVIF) and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers; HEIF gives the primary image's `Exif` and XMP items, read from the box structure without decoding (`src/heif.rs`). |
| `parse_xmp(&str) -> Result<XmpData>` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). |
| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `<?xpacket?>` packet; the second argument is LightCraft's full develop settings JSON. |
| `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). |
| `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. |
| `embeds_location(&[u8]) -> bool` | Whether a file carries GPS anywhere it can be read from (export's "remove location" check): for HEIF every Exif and XMP item, whichever image it describes, with unreadable items counted as a location; otherwise what `extract` reads. |
`Metadata` carries camera (make, model, serial, software), lens (make, model, serial, specification), capture
(date-time with UTC offset and milliseconds, exposure time, f-number, ISO incl. extended ISO, focal length and
@@ -30,7 +31,8 @@ and node count are limited.
## Robustness
No panics on malformed input: proptests feed random bytes to every container walker and random text to the
XMP parser; truncation tests cut a sample JPEG at every byte.
XMP parser; truncation tests cut a sample JPEG at every byte, and a HEIF file at every byte (and set every byte to
four values). The HEIF reader's boxes, items and gathered bytes are bounded and it walks `iloc` once.
## Sources
@@ -38,4 +40,9 @@ CIPA DC-008 (Exif 2.32), TIFF 6.0, Adobe DNG 1.7 (tag numbers), ISO 16684-1 / Ad
Parts 1–3 (packet, RDF serialisation, standard schemas, JPEG extended XMP), IPTC-IIM 4.2 and the IPTC Photo
Metadata Standard, ITU-T T.81 (JPEG markers), ICC.1 (APP2 embedding), PNG Specification 3rd ed. (`eXIf`,
`iTXt`), the WebP RIFF container specification, Adobe Photoshop File Format Specification (image resource
blocks). XML parsing: `quick-xml` (MIT). No third-party metadata code was consulted.
blocks), ISO/IEC 14496-12 (ISO base media file format: boxes, `meta`, `iinf`, `iloc`, `iref`, `idat`) and
ISO/IEC 23008-12 (HEIF: items, `pitm`, `cdsc` and `dimg` references, the `Exif` item of annex A). XML parsing:
`quick-xml` (MIT). No third-party metadata reader was consulted (heic-rs's metadata test file, MIT / Apache-2.0,
was glanced at only while looking for sample files; its reader was not read). For a HEIF test, the code of avif-serialize
(BSD-3-Clause, the AVIF writer behind our AVIF export) was read only to learn how it lays out the `Exif` item it
writes; files from Apple ImageIO, libheif and ExifTool were examined as files only.
+14
View File
@@ -7,8 +7,11 @@
//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks.
//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and
//! IPTC (33723) from IFD0.
//! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block.
//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the
//! XMP `uuid` box.
//! - HEIF, HEIC and AVIF (ISO base media file, see `crate::heif`): the primary image's `Exif` item and XMP
//! (`mime`) item, found through `meta` / `iinf` / `iloc` / `iref`; no picture is decoded.
use lightcraft_tiff::{Tiff, tags};
@@ -36,9 +39,20 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
_ => Embedded::default(),
};
}
if let Some(m) = crate::mrw::parse(bytes) {
// Minolta MRW: the `TTW` block is a complete TIFF with the Exif data
return m
.block(b"TTW")
.filter(|t| Tiff::parse(t).is_ok())
.map(|t| Embedded { exif: Some(t.to_vec()), ..Default::default() })
.unwrap_or_default();
}
if let Some(c) = crate::cr3::parse_cr3(bytes) {
return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() };
}
if let Some(e) = crate::heif::embedded(bytes) {
return e;
}
if bytes.starts_with(&[0xff, 0xd8]) {
jpeg_segments(bytes)
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
+10 -9
View File
@@ -256,16 +256,17 @@ pub fn parse_cr3(bytes: &[u8]) -> Option<Cr3<'_>> {
Some(out)
}
struct BoxRef {
kind: [u8; 4],
/// One box: its type and where its payload is (also used by [`crate::heif`]).
pub(crate) struct BoxRef {
pub(crate) kind: [u8; 4],
/// Start of the payload (after the header and, for `uuid`, the 16-byte uuid).
body: usize,
end: usize,
uuid: Option<[u8; 16]>,
pub(crate) body: usize,
pub(crate) end: usize,
pub(crate) uuid: Option<[u8; 16]>,
}
/// The boxes directly inside `[from, to)`; stops at the first malformed header.
fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec<BoxRef> {
pub(crate) fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec<BoxRef> {
let mut out = Vec::new();
let mut at = from;
let to = to.min(bytes.len());
@@ -407,13 +408,13 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
Cr3TrackKind::Raw { width, height, cmp1, iad1 }
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
pub(crate) fn be16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]))
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
pub(crate) fn be32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
fn be64(b: &[u8], at: usize) -> Option<u64> {
pub(crate) fn be64(b: &[u8], at: usize) -> Option<u64> {
let s = b.get(at..at.checked_add(8)?)?;
let mut a = [0u8; 8];
a.copy_from_slice(s);
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -1,4 +1,4 @@
//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`), XMP packets
//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`, HEIF `Exif` items), XMP packets
//! (read + write, standard namespaces plus our own `lc:` namespace) and basic IPTC-IIM.
//!
//! Entry points:
@@ -7,7 +7,7 @@
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3).
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3, HEIF/HEIC/AVIF).
//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks).
#![forbid(unsafe_code)]
@@ -16,7 +16,9 @@ pub mod cr3;
mod datetime;
mod exif;
mod gpx;
mod heif;
mod iptc;
pub mod mrw;
pub mod tags;
mod xmp;
mod xmp_merge;
@@ -283,6 +285,14 @@ pub fn extract(bytes: &[u8]) -> Metadata {
m
}
/// Does the file embed a GPS position anywhere it can be read from? Export's "remove location"
/// asks before copying an original byte for byte. For HEIF, HEIC and AVIF that is any EXIF or XMP
/// item, whichever image it describes (a thumbnail's too), and an item that can't be read counts
/// as a location; for other files, what [`extract`] reads.
pub fn embeds_location(bytes: &[u8]) -> bool {
heif::embeds_location(bytes).unwrap_or_else(|| extract(bytes).gps.is_some())
}
/// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`.
pub(crate) fn parse_number(s: &str) -> Option<f64> {
let s = s.trim();
+93
View File
@@ -0,0 +1,93 @@
//! Minolta / Konica Minolta MRW block container.
//!
//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box
//! analysis of the CC0 samples on raw.pixls.us. The file starts with `\0MRM` and a big-endian `u32` length; the
//! sensor data begins right after that many bytes (at `8 + length`). In between is a sequence of blocks, each a
//! four-byte name (`\0PRD`, `\0TTW`, `\0WBG`, `\0RIF`, `\0PAD`), a big-endian `u32` length and that many bytes.
//! `\0TTW` holds a complete big-endian TIFF (IFD0, Exif, maker note) whose offsets are relative to the block.
/// The blocks and the offset of the sensor data in an MRW file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mrw<'a> {
/// `(name without the leading NUL, contents)`, in file order.
pub blocks: Vec<([u8; 3], &'a [u8])>,
/// Absolute offset of the sensor data.
pub data_offset: usize,
}
impl<'a> Mrw<'a> {
/// The first block called `name` (e.g. `b"PRD"`).
pub fn block(&self, name: &[u8; 3]) -> Option<&'a [u8]> {
self.blocks.iter().find(|(n, _)| n == name).map(|(_, d)| *d)
}
}
/// At most this many blocks are walked (real files have four or five).
const MAX_BLOCKS: usize = 64;
fn be32(b: &[u8], at: usize) -> Option<usize> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize)
}
/// Parse the block list of an MRW file. `None` when the magic is missing or the header length points past the
/// file; a block that runs past the header is dropped together with everything after it.
pub fn parse(bytes: &[u8]) -> Option<Mrw<'_>> {
if !bytes.starts_with(b"\0MRM") {
return None;
}
let data_offset = be32(bytes, 4)?.checked_add(8).filter(|&o| o <= bytes.len())?;
let mut blocks = Vec::new();
let mut at = 8usize;
while blocks.len() < MAX_BLOCKS && at.saturating_add(8) <= data_offset {
let (Some(name), Some(len)) = (bytes.get(at..at + 4), be32(bytes, at + 4)) else { break };
let start = at + 8;
let Some(end) = start.checked_add(len).filter(|&e| e <= data_offset) else { break };
let Some(data) = bytes.get(start..end) else { break };
if name[0] == 0 {
blocks.push(([name[1], name[2], name[3]], data));
}
at = end;
}
Some(Mrw { blocks, data_offset })
}
#[cfg(test)]
mod tests {
use super::*;
fn block(name: &[u8; 3], data: &[u8]) -> Vec<u8> {
let mut v = vec![0];
v.extend_from_slice(name);
v.extend_from_slice(&(data.len() as u32).to_be_bytes());
v.extend_from_slice(data);
v
}
#[test]
fn walks_blocks_and_finds_the_data() {
let mut body = block(b"PRD", &[1, 2, 3]);
body.extend(block(b"WBG", &[4; 12]));
let mut f = b"\0MRM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
f.extend_from_slice(&[9, 9]);
let m = parse(&f).unwrap();
assert_eq!(m.block(b"PRD"), Some(&[1u8, 2, 3][..]));
assert_eq!(m.block(b"WBG").map(<[u8]>::len), Some(12));
assert_eq!(m.block(b"TTW"), None);
assert_eq!(m.data_offset, f.len() - 2);
}
#[test]
fn hostile_lengths_never_panic() {
assert_eq!(parse(b"\0MRM"), None);
assert_eq!(parse(b"\0MRM\xff\xff\xff\xff"), None);
// a block claiming more than the header holds is dropped
let f = b"\0MRM\0\0\0\x0c\0PRD\xff\xff\xff\xff\0\0\0\0";
assert_eq!(parse(f).map(|m| m.blocks.len()), Some(0));
for n in 0..f.len() {
let _ = parse(&f[..n]);
}
}
}
+35 -3
View File
@@ -20,6 +20,15 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
/// How much of the sharpening a pixel gets (0..1) under Masking `masking` (0..1), from its fine
/// detail `det` (log luminance minus its texture blur): 1 everywhere at 0, only stronger edges
/// as it rises. The GPU kernel (`finish.wgsl`) computes the same; Alt-drag on Masking shows it
/// ([`crate::Overlay::SharpenMask`]).
#[inline]
pub fn sharpen_mask_weight(masking: f32, det: f32) -> f32 {
if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 }
}
/// Parametric region curve (encoded domain) composed with the master point curve.
fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> {
let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0;
@@ -111,6 +120,22 @@ pub const MASK_TERMS: usize = 21;
/// Number of alpha-weighted terms at the start of [`mask_terms`].
pub const MASK_SUMS: usize = 17;
/// Positive dehaze from process V3 (#1237) of scene-linear `c`: `dz` the strength (0..1, with a
/// local adjustment's added), `d` the dark channel over the airlight (0..1), `air` the airlight.
/// The strength eases in (`dz·(0.4 + 0.6·dz)`: +10 acts at about half V1's), the transmission is
/// floored at 0.15, and the veil removed never exceeds 90 % of the pixel's darkest channel, so no
/// pixel is pushed to black (V1 clipped shadows next to bright sky from +10). The GPU mirrors it in
/// `finish.wgsl`. Sources: He, Sun, Tang, "Single Image Haze Removal Using Dark Channel Prior"
/// (CVPR 2009), with own easing and veil limit.
#[inline]
pub fn dehaze_v3_pixel(c: [f32; 3], dz: f32, d: f32, air: f32) -> [f32; 3] {
let k = dz.clamp(0.0, 1.0);
let k = k * (0.4 + 0.6 * k);
let t = (1.0 - 0.95 * k * d).max(0.15);
let veil = (air * (1.0 - t)).min(0.9 * c[0].min(c[1]).min(c[2]).max(0.0));
c.map(|v| ((v - veil) / t).max(0.0))
}
/// A mask's local adjustments as the per-pixel stage uses them (alpha-weighted sums), in order:
/// exposure, temp, tint, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze,
/// saturation, hue, sharpness, noise, moiré, defringe, then the colour overlay: on (0/1), cos and
@@ -174,6 +199,8 @@ pub struct FinishParams {
pub clar: f32,
pub tex: f32,
pub dehaze: f32,
/// Dehaze's process-V3 response ([`dehaze_v3_pixel`]) instead of V1's.
pub dehaze_v3: bool,
pub sharpen: f32,
pub sharpen_mask: f32,
/// Airlight after and before exposure, exposure gain and EV (see [`crate::Prepared`]).
@@ -243,6 +270,7 @@ impl FinishParams {
clar,
tex,
dehaze,
dehaze_v3: matches!(s.process.process(), Process::V3),
sharpen: (s.detail.sharpen_amount / 150.0) as f32,
sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32,
air: air_pre * gain,
@@ -281,7 +309,7 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
let l = &s.light;
match s.process.process() {
// V2 changes the Highlights/Shadows base (`local::tone_base`), not the tone map
Process::V1 | Process::V2 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Process::V1 | Process::V2 | Process::V3 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak),
None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak),
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
@@ -377,6 +405,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
clar,
tex,
dehaze,
dehaze_v3,
sharpen,
sharpen_mask,
air,
@@ -386,6 +415,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
grain,
..
} = fp;
let dehaze_v3 = *dehaze_v3;
let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) =
(*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev);
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
@@ -451,7 +481,9 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
&& let Some(dark) = &p.dark
{
let d = (dark.data[i] / air_pre).clamp(0.0, 1.0);
if dz > 0.0 {
if dz > 0.0 && dehaze_v3 {
c = dehaze_v3_pixel(c, dz, d, air);
} else if dz > 0.0 {
let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12);
c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0));
} else {
@@ -509,7 +541,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs());
delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame;
if sp != 0.0 {
let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 };
let m = sharpen_mask_weight(sharpen_mask, det);
delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m;
}
}
+23 -9
View File
@@ -236,9 +236,10 @@ pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_cro
///
/// Each stage is keyed by exactly the inputs it depends on: the resampled source by the source
/// buffer, framing and output size; the white-balanced, retouched, denoised image by those plus
/// white balance, spots and noise reduction; each spatial plane by that plus its own radius. So a
/// tone or colour slider drag (or exposure) re-runs only the per-pixel stage, a clarity drag skips
/// resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a
/// white balance, spots and noise reduction; each spatial plane by that plus its own radius; each
/// mask's alpha by its definition, the framing and, for masks that read them, that image and the
/// exposure ([`masks::alpha_key`]). So a tone or colour slider drag (or exposure) re-runs only the
/// per-pixel stage, a clarity drag skips resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a
/// full-size render of the same view both stay warm).
pub struct StageCache {
entries: Mutex<Vec<CacheEntry>>,
@@ -300,9 +301,12 @@ impl StageCache {
}
let pl = &e.planes;
let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1));
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) {
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)).chain(e.masks.planes()) {
add(Arc::as_ptr(p) as usize, size(p));
}
if let Some((_, c)) = &pl.chroma {
add(Arc::as_ptr(c) as usize, size(c));
}
}
total
}
@@ -341,6 +345,9 @@ struct CacheEntry {
sampled: Arc<Rgb32f>,
lin: Option<(u64, Arc<Rgb32f>)>,
planes: local::Planes,
/// Alphas of the last render's masks (kept across changes to the image: those of masks that
/// don't read it stay valid).
masks: masks::AlphaCache,
}
fn hash_of(parts: impl std::hash::Hash) -> u64 {
@@ -563,6 +570,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
}
};
lap("wb/spots/nr", &mut t);
let mut alphas = cached.as_ref().map(|e| e.masks.clone()).unwrap_or_default();
let mut planes = match cached.map(|e| e.planes) {
Some(p) if p.key == lin_key => p,
// A draft while noise reduction changes (a slider drag): the wide, edge-aware planes of the
@@ -572,13 +580,14 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(),
_ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() },
};
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref());
let keys = masks::AlphaKeys { geo, image: lin_key };
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_ref(), (&mut alphas, keys));
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("prepare", &mut t);
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes, masks: alphas });
}
if req.depth != OutputDepth::U8 {
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof);
@@ -641,12 +650,15 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
Some(acc.map(|v| v / n))
}
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
/// fresh evaluation.
/// The plane an overlay shows: a sharpening view's detail plane, or the alpha of a mask overlay
/// (the one the render evaluated, or for a hidden mask a fresh evaluation).
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
if o.reads_detail() {
return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, prep.texture_blur.as_deref(), plan.px_per_long);
}
let m = o.mask(&plan.settings)?;
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
return Some(e.alpha.clone());
return Some((*e.alpha).clone());
}
let ev = plan.settings.light.exposure as f32;
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
@@ -688,6 +700,8 @@ pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_dehaze;
#[cfg(test)]
mod tests_geometry;
#[cfg(test)]
mod tests_hdr;
+97 -16
View File
@@ -147,7 +147,7 @@ pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64)
/// edited on a rendered photo or a raw without matrices never pass for Kelvin when pasted onto one that has them.
pub fn normalize_wb(d: &mut DevelopSettings, info: &SourceInfo) {
use lightcraft_develop::{WbMode, WbScale};
if !(info.raw && !info.relative_wb) {
if !info.raw || info.relative_wb {
d.wb.scale = WbScale::Legacy;
return;
}
@@ -243,6 +243,69 @@ fn median_row(src: &[f32], out: &mut [f32], r: usize) {
}
}
/// Separable minimum (`2r+1` px along each row, then each column; clamped edges): a square
/// erosion. Dehaze's patch dark channel from process V3 (#1237).
pub fn min_hv(p: &Plane, r: usize) -> Plane {
if r == 0 || p.data.is_empty() {
return p.clone();
}
let rows = |src: &Plane| {
let mut out = Plane::new(src.width, src.height);
for_rows(&mut out.data, src.width, |y, row| min_row(src.row(y), row, r));
out
};
transpose(&rows(&transpose(&rows(p))))
}
/// Minimum of `src` over `2r+1` taps (clamped to the row) into `out`, in O(1) per pixel (van Herk /
/// Gil–Werman: block-wise running minima from both ends).
fn min_row(src: &[f32], out: &mut [f32], r: usize) {
let n = src.len();
let k = 2 * r + 1;
// padded with +inf: r before, enough after to complete the last block
let len = (n + 2 * r).div_ceil(k) * k;
let at = |i: usize| if i >= r { src.get(i - r).copied().unwrap_or(f32::INFINITY) } else { f32::INFINITY };
let mut fwd = vec![f32::INFINITY; len];
let mut bwd = vec![f32::INFINITY; len];
for i in 0..len {
let v = at(i);
fwd[i] = if i % k == 0 { v } else { v.min(fwd[i - 1]) };
}
for i in (0..len).rev() {
let v = at(i);
bwd[i] = if i % k == k - 1 || i + 1 == len { v } else { v.min(bwd[i + 1]) };
}
for (x, o) in out.iter_mut().enumerate().take(n) {
// window [x, x + 2r] in padded coordinates
*o = bwd.get(x).copied().unwrap_or(f32::INFINITY).min(fwd.get(x + 2 * r).copied().unwrap_or(f32::INFINITY));
}
}
/// Patch radius of dehaze's dark channel from process V3, as a fraction of the long edge.
pub const DEHAZE_PATCH: f64 = 0.005;
/// Largest [`dehaze_patch_radius`] (px).
pub const DEHAZE_PATCH_MAX: usize = 64;
/// Patch (minimum) radius of the dehaze dark channel of `s`'s process at `px_per_long`: 0 (the
/// per-pixel minimum) before V3.
pub fn dehaze_patch_radius(s: &DevelopSettings, px_per_long: f64) -> usize {
match s.process.process() {
Process::V1 | Process::V2 => 0,
Process::V3 => ((DEHAZE_PATCH * px_per_long).round().max(1.0) as usize).min(DEHAZE_PATCH_MAX),
}
}
/// Cache key of the dark-channel plane: its blur radius and patch radius.
pub fn dark_key(sigma: f32, patch: usize) -> u64 {
u64::from(sigma.to_bits()) | ((patch as u64) << 32)
}
/// The dehaze dark channel: the Gaussian (`sigma`) of the `patch`-px minimum of each pixel's
/// darkest channel.
pub fn dark_plane(img: &Rgb32f, sigma: f32, patch: usize) -> Plane {
gaussian(&min_hv(&img.map(dark_of), patch), sigma)
}
fn transpose(p: &Plane) -> Plane {
let (w, h) = (p.width, p.height);
let mut t = Plane::new(h, w);
@@ -273,7 +336,7 @@ pub fn base_detail_radius(px_per_long: f64) -> usize {
pub fn tone_base_detail(s: &DevelopSettings, px_per_long: f64) -> usize {
match s.process.process() {
Process::V1 => 0,
Process::V2 => base_detail_radius(px_per_long),
Process::V2 | Process::V3 => base_detail_radius(px_per_long),
}
}
@@ -384,7 +447,7 @@ pub(crate) struct Planes {
pub clarity: Option<(u32, Arc<Plane>)>,
pub texture: Option<(u32, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
pub dark: Option<(u64, Arc<Plane>, f32)>,
/// Blurred chromaticity (local Moiré / Noise).
pub chroma: Option<(u32, Arc<Rgb32f>)>,
}
@@ -448,10 +511,17 @@ pub struct PlaneSigmas {
pub texture: Option<f32>,
/// Dehaze dark channel (Gaussian).
pub dark: Option<f32>,
/// Patch radius (px) of that dark channel ([`dehaze_patch_radius`]: 0 before V3).
pub dark_patch: usize,
/// Chromaticity blur for local Moiré / colour noise (Gaussian).
pub chroma: Option<f32>,
}
/// Gaussian sigma (pixels) of the texture / sharpening band at `px_per_long`.
pub fn texture_sigma(px_per_long: f64) -> f32 {
(0.0018 * px_per_long as f32).max(0.6)
}
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
let ppl = px_per_long as f32;
let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0);
@@ -472,15 +542,16 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|| local_any(|a| a.sharpness)
|| local_any(|a| a.noise)
|| s.masks.iter().any(|m| m.adjust.defringe > 0.0))
.then(|| (0.0018 * ppl).max(0.6));
.then(|| texture_sigma(px_per_long));
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 };
PlaneSigmas { base, base_detail, clarity, texture, dark, chroma }
let dark_patch = if dark.is_some() { dehaze_patch_radius(s, px_per_long) } else { 0 };
PlaneSigmas { base, base_detail, clarity, texture, dark, dark_patch, chroma }
}
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
/// reusing whatever `planes` already holds for it. Missing planes are computed side by side (each
/// The spatial planes and mask alphas the per-pixel stage needs for `img` (white-balanced, before
/// exposure), reusing whatever `planes` and `alphas` already hold for it. Missing planes are computed side by side (each
/// one alone scales poorly: the guided filters work on small subsampled grids).
#[allow(clippy::too_many_arguments)]
pub(crate) fn prepare(
@@ -490,11 +561,19 @@ pub(crate) fn prepare(
px_per_long: f64,
q: Quality,
planes: &mut Planes,
mattes: Option<&masks::Mattes>,
mattes: Option<&Arc<masks::Mattes>>,
alphas: (&mut masks::AlphaCache, masks::AlphaKeys),
) -> Prepared {
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
plane_sigmas(s, px_per_long, q);
let PlaneSigmas {
base: base_sigma,
base_detail,
clarity: clarity_sigma,
texture: texture_sigma,
dark: dark_sigma,
dark_patch,
chroma: chroma_sigma,
} = plane_sigmas(s, px_per_long, q);
let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes;
let chroma_blur = chroma_sigma.map(|sg| match sch {
@@ -516,11 +595,11 @@ pub(crate) fn prepare(
|| texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))),
|| {
dark_sigma.map(|sg| match sd {
Some((k, d, air)) if *k == sg.to_bits() => (d.clone(), *air),
Some((k, d, air)) if *k == dark_key(sg, dark_patch) => (d.clone(), *air),
_ => {
let d = timed("dehaze", || Arc::new(gaussian(&img.map(dark_of), sg)));
let d = timed("dehaze", || Arc::new(dark_plane(&img, sg, dark_patch)));
let air = airlight(&d);
*sd = Some((sg.to_bits(), d.clone(), air));
*sd = Some((dark_key(sg, dark_patch), d.clone(), air));
(d, air)
}
})
@@ -536,7 +615,8 @@ pub(crate) fn prepare(
None => (None, 1.0),
};
let ev = s.light.exposure as f32;
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes));
let (cache, keys) = alphas;
let masks = timed("masks", || masks::evaluate_cached(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes, keys, cache));
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
}
@@ -545,8 +625,9 @@ pub(crate) fn prepare(
pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 {
let mut img = frame.sample(src, proxy_w, proxy_h);
white_balance(&mut img, info, s);
let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32;
airlight(&gaussian(&img.map(dark_of), sigma))
let ppl = frame.px_per_long(proxy_w);
let sigma = (0.02 * ppl).max(1.0) as f32;
airlight(&dark_plane(&img, sigma, dehaze_patch_radius(s, ppl)))
}
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.
+336 -56
View File
@@ -9,6 +9,7 @@
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape, SegMask};
use lightcraft_geom::{Orientation, Point};
use lightcraft_raster::{Image, Plane, Rgb32f};
use std::sync::Arc;
use crate::for_rows;
use crate::geometry::Frame;
@@ -92,10 +93,22 @@ impl Mattes {
}
}
/// Whether automatic selection is a classical approximation rather than a stored model result
/// or source matte. Frontends should make this visible instead of implying AI segmentation.
pub fn approximate_selection(shape: &MaskShape, mattes: Option<&Mattes>) -> bool {
match shape {
MaskShape::Background => approximate_selection(&MaskShape::Subject, mattes),
MaskShape::Sky | MaskShape::Subject | MaskShape::People { .. } => mattes.is_none_or(|m| m.for_shape(shape).is_empty()),
MaskShape::Object { hint, seg, detail, .. } => !hint.is_empty() && seg.is_none() && detail.is_empty(),
_ => false,
}
}
pub struct Evaluated {
/// The mask's id ([`Mask::id`]).
pub id: u32,
pub alpha: Plane,
/// Shared with the [`AlphaCache`] that keeps it for the next render.
pub alpha: Arc<Plane>,
pub adjust: LocalAdjustments,
}
@@ -111,10 +124,176 @@ pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f,
masks
.iter()
.filter(|m| m.visible && !m.components.is_empty())
.map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev, mattes), adjust: m.adjust })
.map(|m| Evaluated { id: m.id, alpha: Arc::new(evaluate_one(m, frame, w, h, img, log_l, ev, mattes)), adjust: m.adjust })
.collect()
}
/// What a render's masks are evaluated against besides their own definitions: [`alpha_key`] keys
/// each alpha by the parts of it the mask reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AlphaKeys {
/// The output frame and size (`Plan::geo`): every mask depends on it.
pub geo: u64,
/// The image the masks see, before exposure (`Plan::lin_key`): range masks, the Sky / Subject
/// / Background heuristics, Auto Mask brushes and Refine Edges read it.
pub image: u64,
}
/// Alpha planes (`P`: a CPU [`Plane`], or a renderer's device buffer) of the last render's visible
/// masks, keyed by [`alpha_key`]. A slider drag that changes nothing a mask reads (Exposure for
/// gradients and brushes, Clarity, the mask's own sliders except Amount) reuses its alpha instead
/// of evaluating it again. Holds only the masks of the last render (one plane per visible mask),
/// tied to the segmentation mattes they were evaluated with.
pub struct AlphaCache<P = Plane> {
mattes: Option<Arc<Mattes>>,
slots: Vec<(u64, Arc<P>)>,
}
impl<P> Clone for AlphaCache<P> {
fn clone(&self) -> Self {
AlphaCache { mattes: self.mattes.clone(), slots: self.slots.clone() }
}
}
impl<P> Default for AlphaCache<P> {
fn default() -> Self {
AlphaCache { mattes: None, slots: Vec::new() }
}
}
impl<P> AlphaCache<P> {
/// The alphas held for a render with `mattes` (none when the mattes changed), taken out of
/// the cache: hand back the ones the render used with [`Self::keep`].
pub fn take(&mut self, mattes: Option<&Arc<Mattes>>) -> Vec<(u64, Arc<P>)> {
let same = match (&self.mattes, mattes) {
(None, None) => true,
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
_ => false,
};
let slots = std::mem::take(&mut self.slots);
if same { slots } else { Vec::new() }
}
/// Hold `slots` (the alphas a render with `mattes` used), dropping everything else.
pub fn keep(&mut self, mattes: Option<&Arc<Mattes>>, slots: Vec<(u64, Arc<P>)>) {
self.mattes = mattes.cloned();
self.slots = slots;
}
/// The alphas held.
pub fn planes(&self) -> impl Iterator<Item = &Arc<P>> {
self.slots.iter().map(|(_, p)| p)
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
/// Whether the alpha of mask `m` reads the image (`.0`) and the global exposure (`.1`), beyond
/// its own definition and the frame. Errs towards yes: a mask said to read something it doesn't
/// is only evaluated more often than it needs to be.
pub fn reads(m: &Mask) -> (bool, bool) {
let (mut image, mut exposure) = (m.refine > 0.0, false);
for c in &m.components {
let (i, e) = match &c.shape {
MaskShape::Linear { .. } | MaskShape::Radial { .. } | MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => (false, false),
MaskShape::Prompt { .. } => (false, false),
// a stored segmentation (or no clicks) reads nothing; clicks without one take the heuristic
MaskShape::Object { seg, detail, .. } => (seg.is_none() && detail.is_empty(), false),
MaskShape::Brush { strokes } => (strokes.iter().any(|s| s.auto_mask), false),
// (with a source matte these read neither: counted as if they did)
MaskShape::LuminanceRange { .. } | MaskShape::ColorRange { .. } | MaskShape::Sky => (true, true),
MaskShape::Subject | MaskShape::Background | MaskShape::People { .. } => (true, false),
};
image |= i;
exposure |= e;
}
(image, exposure)
}
/// Feeds `Debug` output to a hasher without building the string.
struct HashWrite<'a, H: std::hash::Hasher>(&'a mut H);
impl<H: std::hash::Hasher> std::fmt::Write for HashWrite<'_, H> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.write(s.as_bytes());
Ok(())
}
}
/// The key of mask `m`'s alpha in a render with `keys` and global exposure `ev`: everything
/// [`evaluate_one`] reads — its components, Refine Edges, Invert and Amount, the frame, and the
/// image and exposure for the masks that read them ([`reads`]). Not its other sliders, name or id.
pub fn alpha_key(m: &Mask, keys: AlphaKeys, ev: f32) -> u64 {
use std::fmt::Write;
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher, Hash, Hasher};
let (image, exposure) = reads(m);
let mut h = BuildHasherDefault::<DefaultHasher>::default().build_hasher();
keys.geo.hash(&mut h);
image.then_some(keys.image).hash(&mut h);
exposure.then_some(ev.to_bits()).hash(&mut h);
(m.refine.to_bits(), m.invert, m.adjust.amount.to_bits()).hash(&mut h);
// (`Debug` of an f64 round-trips: distinct definitions write distinct text)
let _ = write!(HashWrite(&mut h), "{:?}", m.components);
h.finish()
}
/// [`evaluate`], reusing the alphas `cache` holds for masks whose inputs are unchanged and keeping
/// this render's there. The result is identical to [`evaluate`]'s.
#[allow(clippy::too_many_arguments)]
pub fn evaluate_cached(
masks: &[Mask],
frame: &Frame,
w: usize,
h: usize,
img: &Rgb32f,
log_l: &Plane,
ev: f32,
mattes: Option<&Arc<Mattes>>,
keys: AlphaKeys,
cache: &mut AlphaCache,
) -> Vec<Evaluated> {
let old = cache.take(mattes);
let mut used = Vec::new();
let out = masks
.iter()
.filter(|m| m.visible && !m.components.is_empty())
.map(|m| {
let key = alpha_key(m, keys, ev);
let alpha = match old.iter().find(|(k, _)| *k == key) {
Some((_, a)) => a.clone(),
None => Arc::new(evaluate_one(m, frame, w, h, img, log_l, ev, mattes.map(|a| &**a))),
};
used.push((key, alpha.clone()));
Evaluated { id: m.id, alpha, adjust: m.adjust }
})
.collect();
cache.keep(mattes, used);
out
}
/// `f(a, b)` for each value `a` of `p` and the value `b` at the same index of `q`, row-parallel.
fn zip_rows(p: &mut Plane, q: &Plane, f: impl Fn(&mut f32, f32) + Sync + Send) {
let w = p.width;
for_rows(&mut p.data, w, |y, row| {
let Some(src) = q.data.get(y * w..y * w + row.len()) else { return };
for (a, b) in row.iter_mut().zip(src) {
f(a, *b);
}
});
}
/// `f` on each value of `p`, row-parallel.
fn map_rows(p: &mut Plane, f: impl Fn(&mut f32) + Sync + Send) {
let w = p.width;
for_rows(&mut p.data, w, |_, row| row.iter_mut().for_each(&f));
}
/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and
/// scaled by its amount.
#[allow(clippy::too_many_arguments)]
@@ -124,19 +303,17 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
for comp in &m.components {
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev, mattes);
if comp.invert {
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
map_rows(&mut c, |v| *v = 1.0 - *v);
}
if first && comp.op != MaskOp::Intersect {
alpha = c;
first = false;
continue;
}
for (a, c) in alpha.data.iter_mut().zip(&c.data) {
*a = match comp.op {
MaskOp::Add => a.max(*c),
MaskOp::Subtract => *a * (1.0 - c),
MaskOp::Intersect => *a * c,
};
match comp.op {
MaskOp::Add => zip_rows(&mut alpha, &c, |a, c| *a = a.max(c)),
MaskOp::Subtract => zip_rows(&mut alpha, &c, |a, c| *a *= 1.0 - c),
MaskOp::Intersect => zip_rows(&mut alpha, &c, |a, c| *a *= c),
}
first = false;
}
@@ -146,16 +323,15 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32;
let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0);
let refined = guided_cross(log_l, &alpha, sigma, 0.02);
for (a, r) in alpha.data.iter_mut().zip(&refined.data) {
*a += (r - *a) * k.sqrt();
}
let t = k.sqrt();
zip_rows(&mut alpha, &refined, |a, r| *a += (r - *a) * t);
}
if m.invert {
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
map_rows(&mut alpha, |v| *v = 1.0 - *v);
}
let amt = (m.adjust.amount / 100.0) as f32;
if (amt - 1.0).abs() > 1e-6 {
alpha.data.iter_mut().for_each(|v| *v *= amt);
map_rows(&mut alpha, |v| *v *= amt);
}
alpha
}
@@ -210,37 +386,42 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
MaskShape::Brush { strokes } => rasterize_brush(strokes, frame, w, h, img, log_l, &mut out),
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
let (lo, hi, lf, hf) = (*lo as f32, *hi as f32, (*lo_feather as f32).max(1e-3), (*hi_feather as f32).max(1e-3));
for (v, l) in out.data.iter_mut().zip(&log_l.data) {
zip_rows(&mut out, log_l, |v, l| {
// map log luminance to a 0..1 perceptual scale (−8..+4 EV)
let y = ((l + ev + 8.0) / 12.0).clamp(0.0, 1.0);
*v = smooth(lo - lf, lo, y) * (1.0 - smooth(hi, hi + hf, y));
}
});
}
MaskShape::ColorRange { samples, refine } => {
let tol = color_range_tolerance(*refine);
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
for (v, c) in out.data.iter_mut().zip(&img.data) {
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
let d = samples
.iter()
.map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt())
.fold(f32::MAX, f32::min);
*v = 1.0 - smooth(tol * 0.5, tol, d);
}
for_rows(&mut out.data, w, |y, row| {
let Some(src) = img.data.get(y * w..y * w + row.len()) else { return };
for (v, c) in row.iter_mut().zip(src) {
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
let d = samples
.iter()
.map(|s| ((lab[1] - s[1]).powi(2) + (lab[2] - s[2]).powi(2) + 0.25 * (lab[0] - s[0]).powi(2)).sqrt())
.fold(f32::MAX, f32::min);
*v = 1.0 - smooth(tol * 0.5, tol, d);
}
});
}
MaskShape::Sky => {
// Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or
// unsaturated, and connected to the top of the frame.
let m = frame.out_to_norm(w, h);
for (i, v) in out.data.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let c = img.data[i].map(|v| v * gain);
let l = log_l.data[i] + ev;
let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4);
let top = 1.0 - smooth(0.25, 0.7, n.y as f32);
*v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l)));
}
for_rows(&mut out.data, w, |y, row| {
let (Some(cs), Some(ls)) = (img.data.get(y * w..y * w + row.len()), log_l.data.get(y * w..y * w + row.len())) else { return };
for (x, (v, (c, l))) in row.iter_mut().zip(cs.iter().zip(ls)).enumerate() {
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let c = c.map(|v| v * gain);
let l = l + ev;
let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4);
let top = 1.0 - smooth(0.25, 0.7, n.y as f32);
*v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l)));
}
});
smooth_plane(&mut out, 0.01 * frame_px(frame, w));
}
MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => {
@@ -260,18 +441,20 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
// Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12).
let m = frame.out_to_norm(w, h);
let blur = lightcraft_raster::blur::gaussian(log_l, 0.03 * frame_px(frame, w));
for (i, v) in out.data.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32;
let contrast = (log_l.data[i] - blur.data[i]).abs();
*v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast));
}
for_rows(&mut out.data, w, |y, row| {
let (Some(ls), Some(bs)) = (log_l.data.get(y * w..y * w + row.len()), blur.data.get(y * w..y * w + row.len())) else { return };
for (x, (v, (l, b))) in row.iter_mut().zip(ls.iter().zip(bs)).enumerate() {
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let d = (((n.x - 0.5) / 0.35).powi(2) + ((n.y - 0.55) / 0.4).powi(2)).sqrt() as f32;
let contrast = (l - b).abs();
*v = (1.0 - smooth(0.6, 1.0, d)) * (0.5 + 0.5 * smooth(0.05, 0.6, contrast));
}
});
smooth_plane(&mut out, 0.015 * frame_px(frame, w));
}
MaskShape::Background => {
let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l, ev, mattes);
s.data.iter_mut().for_each(|v| *v = 1.0 - *v);
map_rows(&mut s, |v| *v = 1.0 - *v);
out = s;
}
MaskShape::DepthRange { .. } | MaskShape::Landscape { .. } => {}
@@ -570,19 +753,37 @@ pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane {
let mp = gaussian(p, sigma);
let cip = gaussian(&guide.zip_map(p, |a, b| a * b), sigma);
let cii = gaussian(&guide.map(|a| a * a), sigma);
let w = p.width;
let rows = |y: usize, len: usize| y * w..y * w + len;
// a = cov(I, p) / (var(I) + eps), b = mean(p) − a · mean(I)
let mut a = Plane::new(p.width, p.height);
let mut b = Plane::new(p.width, p.height);
for i in 0..p.data.len() {
let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0);
let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps);
a.data[i] = k;
b.data[i] = mp.data[i] - k * mi.data[i];
}
let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma));
let mut q = Plane::new(p.width, p.height);
for i in 0..q.data.len() {
q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0);
}
for_rows(&mut a.data, w, |y, row| {
let r = rows(y, row.len());
let (Some(cii), Some(mi), Some(cip), Some(mp)) = (cii.data.get(r.clone()), mi.data.get(r.clone()), cip.data.get(r.clone()), mp.data.get(r))
else {
return;
};
for (k, (((cii, mi), cip), mp)) in row.iter_mut().zip(cii.iter().zip(mi).zip(cip).zip(mp)) {
let var = (cii - mi * mi).max(0.0);
*k = (cip - mi * mp) / (var + eps);
}
});
let mut b = mp;
for_rows(&mut b.data, w, |y, row| {
let r = rows(y, row.len());
let (Some(ks), Some(mi)) = (a.data.get(r.clone()), mi.data.get(r)) else { return };
for (b, (k, mi)) in row.iter_mut().zip(ks.iter().zip(mi)) {
*b -= k * mi;
}
});
let (ma, mut q) = (gaussian(&a, sigma), gaussian(&b, sigma));
for_rows(&mut q.data, w, |y, row| {
let r = rows(y, row.len());
let (Some(ma), Some(g)) = (ma.data.get(r.clone()), guide.data.get(r)) else { return };
for (q, (a, g)) in row.iter_mut().zip(ma.iter().zip(g)) {
*q = (a * g + *q).clamp(0.0, 1.0);
}
});
q
}
@@ -626,9 +827,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i
// the refinement fills gaps along edges; it never takes paint away
stroke_alpha = stroke_alpha.zip_map(&q, f32::max);
}
for (o, a) in out.data.iter_mut().zip(&stroke_alpha.data) {
*o = if erase { *o * (1.0 - a) } else { o.max(*a) };
}
zip_rows(out, &stroke_alpha, |o, a| *o = if erase { *o * (1.0 - a) } else { o.max(a) });
}
}
@@ -641,6 +840,21 @@ mod tests {
Frame::new(w, h, &DevelopSettings::default(), true)
}
#[test]
fn selection_provenance_distinguishes_heuristics_mattes_and_model_results() {
assert!(approximate_selection(&MaskShape::Sky, None));
assert!(approximate_selection(&MaskShape::Background, None));
let mut m = Mattes::default();
m.push(MatteKind::Sky, Image::filled(2, 2, 255));
assert!(!approximate_selection(&MaskShape::Sky, Some(&m)));
assert!(approximate_selection(&MaskShape::Subject, Some(&m)));
m.push(MatteKind::Person, Image::filled(2, 2, 255));
assert!(!approximate_selection(&MaskShape::Background, Some(&m)));
let seg = SegMask::from_logits(2, &[8.0, -8.0, 8.0, -8.0]);
let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: Some(seg), detail: vec![], edge: 0.0 };
assert!(!approximate_selection(&shape, None));
}
#[test]
fn linear_gradient_ramps() {
let f = frame(100, 50);
@@ -1015,4 +1229,70 @@ mod tests {
let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] };
assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None));
}
/// The alpha cache's key changes with everything an alpha is evaluated from (a stale key would
/// put a stale mask on screen), and not with the mask's other sliders.
#[test]
fn alpha_keys_change_with_every_input_of_the_alpha() {
let keys = AlphaKeys { geo: 1, image: 2 };
let comp = |shape| MaskComponent { name: None, op: MaskOp::Add, invert: false, shape };
let one = |shape| Mask { components: vec![comp(shape)], ..Default::default() };
let radial = one(MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.3, ry: 0.2, angle: 0.0, feather: 50.0, invert: false });
let lum = one(MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 });
let key = |m: &Mask| alpha_key(m, keys, 0.0);
// a stored segmentation of the same size with other logits
let object = |v: f32| {
let seg = Some(SegMask::from_logits(4, &[v; 16]));
one(MaskShape::Object { hint: vec![Point::new(0.5, 0.5)], exclude: vec![], seg, detail: vec![], edge: 0.0 })
};
assert_ne!(key(&object(4.0)), key(&object(-4.0)));
let edits: [fn(&mut Mask); 7] = [
|m| m.refine = 10.0,
|m| m.invert = true,
|m| m.adjust.amount = 50.0,
|m| m.components[0].invert = true,
|m| m.components[0].op = MaskOp::Intersect,
|m| {
if let MaskShape::Radial { feather, .. } = &mut m.components[0].shape {
*feather = 50.5;
}
},
|m| {
let shape = MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(0.0, 1.0) };
m.components.push(MaskComponent { name: None, op: MaskOp::Subtract, invert: false, shape });
},
];
for (i, edit) in edits.iter().enumerate() {
let mut m = radial.clone();
edit(&mut m);
assert_ne!(key(&m), key(&radial), "edit {i}");
}
let mut m = radial.clone();
(m.id, m.name, m.adjust.exposure, m.adjust.clarity) = (9, "Renamed".into(), 1.0, 30.0);
assert_eq!(key(&m), key(&radial));
// the frame for every mask; the image and the exposure for the masks that read them
let (other_geo, other_image) = (AlphaKeys { geo: 3, ..keys }, AlphaKeys { image: 4, ..keys });
let refined = Mask { refine: 40.0, ..radial.clone() };
for m in [&radial, &lum, &refined] {
assert_ne!(alpha_key(m, other_geo, 0.0), key(m));
}
assert_eq!((alpha_key(&radial, other_image, 0.0), alpha_key(&radial, keys, 1.0)), (key(&radial), key(&radial)));
assert_ne!(alpha_key(&lum, other_image, 0.0), key(&lum));
assert_ne!(alpha_key(&lum, keys, 1.0), key(&lum));
assert_ne!(alpha_key(&refined, other_image, 0.0), key(&refined));
assert_eq!(alpha_key(&refined, keys, 1.0), key(&refined));
}
/// Alphas are only handed back to a render with the segmentation mattes they were evaluated
/// with (another photo's mattes, or none, start empty).
#[test]
fn alpha_cache_is_tied_to_the_mattes() {
let (a, b) = (Arc::new(Mattes::default()), Arc::new(Mattes::default()));
let mut c = AlphaCache::<Plane>::default();
c.keep(Some(&a), vec![(1, Arc::new(Plane::new(2, 2)))]);
assert!(c.clone().take(Some(&b)).is_empty());
assert!(c.clone().take(None).is_empty());
assert_eq!(c.take(Some(&a)).len(), 1);
assert!(c.is_empty());
}
}
+182
View File
@@ -255,6 +255,188 @@ fn drafts_keep_wide_planes_across_a_noise_reduction_change_and_full_renders_do_n
}
}
/// [`typical_edits`] plus masks of every kind the alpha cache must tell apart: ones that read
/// only the frame (gradients, a brush), the image (Auto Mask, Subject, Refine Edges) or the image
/// and the exposure (luminance and colour range, Sky).
fn masked_edits() -> DevelopSettings {
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let comp = |op: MaskOp, shape: MaskShape| MaskComponent { name: None, op, invert: false, shape };
let mask = |id: u32, components: Vec<MaskComponent>, refine: f64| Mask {
id,
components,
adjust: LocalAdjustments { exposure: 0.4 * (id as f64 - 3.0), clarity: 20.0, noise: 30.0, ..Default::default() },
refine,
..Default::default()
};
let stroke = |y: f64, auto_mask: bool| BrushStroke {
points: (0..12).map(|k| Point::new(0.1 + k as f64 * 0.06, y)).collect(),
size: 0.05,
auto_mask,
..Default::default()
};
let mut s = typical_edits();
s.masks = vec![
mask(
1,
vec![
comp(MaskOp::Add, MaskShape::Radial { center: Point::new(0.4, 0.5), rx: 0.3, ry: 0.2, angle: 10.0, feather: 50.0, invert: false }),
comp(MaskOp::Subtract, MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(0.0, 0.4) }),
],
0.0,
),
mask(2, vec![comp(MaskOp::Add, MaskShape::Linear { start: Point::new(0.5, 1.0), end: Point::new(0.5, 0.3) })], 60.0),
mask(3, vec![comp(MaskOp::Add, MaskShape::LuminanceRange { lo: 0.3, hi: 0.7, lo_feather: 0.1, hi_feather: 0.1 })], 0.0),
mask(4, vec![comp(MaskOp::Add, MaskShape::ColorRange { samples: vec![[0.6, -0.05, -0.08]], refine: 50.0 })], 0.0),
mask(5, vec![comp(MaskOp::Add, MaskShape::Brush { strokes: vec![stroke(0.3, false), stroke(0.7, true)] })], 0.0),
mask(6, vec![comp(MaskOp::Add, MaskShape::Sky), comp(MaskOp::Intersect, MaskShape::Background)], 0.0),
];
s
}
/// A cached render with masks is the same image as an uncached one through any edit, global or
/// to a mask, at either size.
#[test]
fn stage_cache_with_masks_matches_uncached_render_through_a_slider_session() {
use crate::{Quality, StageCache, render_cached};
use lightcraft_develop::{BrushStroke, MaskShape};
use lightcraft_geom::Point;
use std::sync::Arc;
let src = Arc::new(scene());
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let full = RenderRequest::fit(200, 200);
let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) };
let mut s = masked_edits();
let steps: Vec<Box<dyn Fn(&mut DevelopSettings)>> = vec![
Box::new(|_| {}),
Box::new(|s| s.light.exposure = -0.6),
Box::new(|s| s.light.contrast = 30.0),
Box::new(|s| s.masks[0].adjust.exposure = 1.2),
Box::new(|s| s.masks[1].refine = 30.0),
Box::new(|s| s.masks[2].components[0].shape = MaskShape::LuminanceRange { lo: 0.2, hi: 0.6, lo_feather: 0.1, hi_feather: 0.2 }),
Box::new(|s| s.masks[3].adjust.amount = 40.0),
Box::new(|s| s.masks[4].invert = true),
Box::new(|s| {
if let MaskShape::Brush { strokes } = &mut s.masks[4].components[0].shape {
strokes.push(BrushStroke { points: vec![Point::new(0.5, 0.5), Point::new(0.6, 0.55)], erase: true, ..Default::default() });
}
}),
Box::new(|s| s.masks[5].visible = false),
Box::new(|s| s.masks.swap(0, 1)),
Box::new(|s| {
s.wb.mode = lightcraft_develop::WbMode::Custom;
s.wb.temp = 4200.0;
}),
Box::new(|s| s.effects.clarity = -20.0),
Box::new(|s| s.masks[5].visible = true),
Box::new(|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.05, 0.1, 0.9, 0.85)),
Box::new(|s| s.light.exposure = 0.9),
// (last: a draft then keeps the wide planes of the image before noise reduction)
Box::new(|s| s.detail.nr_luminance = 40.0),
];
let nr_step = steps.len() - 1;
for (i, step) in steps.iter().enumerate() {
step(&mut s);
for req in [&full, &draft] {
let a = render_cached(&src, &info, &s, req, &cache).image;
let b = render(&src, &info, &s, req).image;
if i != nr_step || req.quality != Quality::Draft {
assert_eq!(a, b, "step {i} {:?}", req.quality);
}
// and the alphas held are those a cold cache evaluates
let fresh = StageCache::default();
render_cached(&src, &info, &s, req, &fresh);
let (warm, cold) = (cached_alphas(&cache), cached_alphas(&fresh));
assert_eq!(warm.len(), cold.len(), "step {i} {:?}", req.quality);
for (k, (a, b)) in warm.iter().zip(&cold).enumerate() {
assert!(**a == **b, "step {i} {:?}: mask {k} is stale", req.quality);
}
}
}
}
/// The alphas held by the stage cache's most recent entry, in mask order.
fn cached_alphas(cache: &crate::StageCache) -> Vec<std::sync::Arc<lightcraft_raster::Plane>> {
cache.entries.lock().unwrap().last().unwrap().masks.planes().cloned().collect()
}
#[test]
fn mask_alphas_are_reused_until_what_they_read_changes() {
use crate::{StageCache, render_cached};
use lightcraft_develop::MaskShape;
use lightcraft_geom::Point;
use std::sync::Arc;
let src = Arc::new(scene());
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let req = RenderRequest::fit(200, 200);
// radial − linear (reads the frame only), linear with Refine Edges (the image), luminance
// range (the image and the exposure)
let mut s = masked_edits();
s.masks.truncate(3);
render_cached(&src, &info, &s, &req, &cache);
let step = |s: &DevelopSettings| -> Vec<bool> {
let before = cached_alphas(&cache);
render_cached(&src, &info, s, &req, &cache);
let after = cached_alphas(&cache);
assert_eq!(after.len(), before.len());
before.iter().zip(&after).map(|(a, b)| Arc::ptr_eq(a, b)).collect()
};
// exposure: only the range mask reads it
s.light.exposure += 0.5;
assert_eq!(step(&s), [true, true, false]);
// a mask's own sliders (except Amount) and global sliders no mask reads
s.masks[0].adjust.exposure = -1.0;
s.masks[2].adjust.clarity = 50.0;
s.effects.clarity = 40.0;
s.light.highlights = 10.0;
assert_eq!(step(&s), [true, true, true]);
// Amount scales the alpha
s.masks[1].adjust.amount = 50.0;
assert_eq!(step(&s), [true, false, true]);
// the image changes (white balance): the masks that read it
s.wb.mode = lightcraft_develop::WbMode::Custom;
s.wb.temp = 4000.0;
assert_eq!(step(&s), [true, false, false]);
// a shape moves: that mask only
s.masks[0].components[0].shape = MaskShape::Radial { center: Point::new(0.6, 0.5), rx: 0.3, ry: 0.2, angle: 10.0, feather: 50.0, invert: false };
assert_eq!(step(&s), [false, true, true]);
// the frame changes (a flip: same size): all of them
s.crop.flip_h = true;
assert_eq!(step(&s), [false, false, false]);
// a hidden mask's alpha is let go: only the visible masks' are held
s.masks[2].visible = false;
render_cached(&src, &info, &s, &req, &cache);
assert_eq!(cached_alphas(&cache).len(), 2);
}
/// The memory a stage cache reports covers every buffer it holds, the mask alphas and the blurred
/// chromaticity (local Noise / Moiré) included.
#[test]
fn stage_cache_bytes_count_mask_alphas_and_the_chroma_plane() {
use crate::{StageCache, render_cached};
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
use std::sync::Arc;
let src = Arc::new(scene());
let info = SourceInfo { raw: true, ..Default::default() };
let cache = StageCache::default();
let mut s = DevelopSettings::default();
let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.3, ry: 0.3, angle: 0.0, feather: 50.0, invert: false };
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { noise: 40.0, ..Default::default() },
..Default::default()
});
let img = render_cached(&src, &info, &s, &RenderRequest::fit(200, 200), &cache).image;
let n = img.width * img.height;
// the sampled and linear images (12 B/px each), log luminance and the texture band that local
// Noise reads (4 B/px each), the blurred chromaticity (12 B/px) and the mask's alpha (4 B/px)
assert_eq!(cache.bytes(), (12 + 12 + 4 + 4 + 12 + 4) * n);
}
#[test]
fn exposure_after_spatial_filters_equals_exposing_the_source() {
// The pipeline filters the image before exposure and applies exposure per pixel; filters on
+132
View File
@@ -0,0 +1,132 @@
//! Dehaze from process V3 (issue #1237): haze is estimated from a patch-minimum dark channel, the
//! response eases in, and no pixel is pushed to black; V1 and V2 keep their rendering.
use lightcraft_develop::{DevelopSettings, Process, controls};
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{RenderRequest, SourceInfo, finish::dehaze_v3_pixel, local, render};
const W: usize = 600;
const H: usize = 300;
/// Left: bright sky (0.8); right: a textured ground (a 0.03 / 0.30 chequer of 6 px cells) whose
/// patches all hold dark pixels: no haze there.
fn scene() -> Rgb32f {
Rgb32f::from_fn(W, H, |x, y| {
let v = if x < W / 2 {
0.8
} else if (x / 6 + y / 6).is_multiple_of(2) {
0.03
} else {
0.30
};
[v; 3]
})
}
fn shot(p: Process, dehaze: f64) -> Rgba8 {
let mut s = DevelopSettings { process: p.version(), ..DevelopSettings::default() };
controls::set(&mut s, "effects.dehaze", dehaze);
render(&scene(), &SourceInfo::default(), &s, &RenderRequest::fit(W, H)).image
}
fn luma(p: [u8; 4]) -> f32 {
0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32
}
/// Mean luma and the share of black (< 2) pixels of the ground, columns `x0..x1`.
fn ground(img: &Rgba8, x0: usize, x1: usize) -> (f32, f32) {
let (mut sum, mut black, mut n) = (0.0, 0.0, 0.0);
for y in 20..H - 20 {
for x in x0..x1 {
let v = luma(img.data[y * W + x]);
sum += v;
black += f32::from(u8::from(v < 2.0));
n += 1.0;
}
}
(sum / n, black / n)
}
#[test]
fn v3_leaves_haze_free_texture_and_shadows_by_the_sky_alone() {
let ev = |a: f32, b: f32| (b / a).log2();
for p in [Process::V1, Process::V3] {
let (m0, _) = ground(&shot(p, 0.0), W / 2 + 20, W);
let (m50, _) = ground(&shot(p, 50.0), W / 2 + 20, W);
let (_, black10) = ground(&shot(p, 10.0), W / 2, W / 2 + 12);
let (_, black50) = ground(&shot(p, 50.0), W / 2, W / 2 + 12);
eprintln!("{p:?}: ground {m0:.1} -> {m50:.1} at +50 ({:.2} EV); black by the sky {black10:.2} (+10) {black50:.2} (+50)", ev(m0, m50));
if p == Process::V3 {
assert!(ev(m0, m50) > -0.25, "V3 darkens a haze-free ground by {:.2} EV at +50", ev(m0, m50));
assert!(black10 < 0.01 && black50 < 0.01, "V3 clips shadows by the sky: {black10} {black50}");
} else {
// V1 is frozen with the faults the issue measured
assert!(ev(m0, m50) < -0.4, "V1 changed: {:.2} EV", ev(m0, m50));
}
}
}
#[test]
fn v3_dehaze_still_clears_a_uniform_veil() {
// a hazy scene: a chequer lifted by a veil
let hazy = Rgb32f::from_fn(W, H, |x, y| [if (x / 6 + y / 6).is_multiple_of(2) { 0.42 } else { 0.6 }; 3]);
let contrast = |d: f64| {
let mut s = DevelopSettings { process: Process::V3.version(), ..DevelopSettings::default() };
controls::set(&mut s, "effects.dehaze", d);
let img = render(&hazy, &SourceInfo::default(), &s, &RenderRequest::fit(W, H)).image;
let (a, b) = (luma(img.data[100 * W + 100]), luma(img.data[100 * W + 106]));
(a - b).abs()
};
let (c0, c50, c100) = (contrast(0.0), contrast(50.0), contrast(100.0));
assert!(c50 > c0 * 1.3 && c100 > c50, "{c0} {c50} {c100}");
}
#[test]
fn v3_pixel_never_goes_black_and_eases_in() {
// a shadow (0.05) whose blurred dark channel is the sky's: V1 clipped it to 0 at +10
let air = 0.8;
for dz in [0.1, 0.25, 0.5, 1.0] {
let c = dehaze_v3_pixel([0.05, 0.06, 0.07], dz, 0.9, air);
assert!(c.iter().all(|v| *v > 0.0), "{dz}: {c:?}");
}
let small = dehaze_v3_pixel([0.4; 3], 0.1, 0.5, air)[0];
let v1_t = 1.0 - 0.95 * 0.1 * 0.5;
let v1 = (0.4 - air * (1.0 - v1_t)) / v1_t;
assert!((0.4 - small) < 0.6 * (0.4 - v1), "eases in: {small} vs V1 {v1}");
assert_eq!(dehaze_v3_pixel([0.3, 0.2, 0.1], 0.0, 0.7, air), [0.3, 0.2, 0.1]);
}
#[test]
fn patch_radius_only_from_v3_and_min_filter_erodes() {
let s = |p: Process| DevelopSettings { process: p.version(), ..DevelopSettings::default() };
assert_eq!(local::dehaze_patch_radius(&s(Process::V1), 3000.0), 0);
assert_eq!(local::dehaze_patch_radius(&s(Process::V2), 3000.0), 0);
assert_eq!(local::dehaze_patch_radius(&s(Process::V3), 3000.0), 15);
assert_eq!(local::dehaze_patch_radius(&s(Process::V3), 1e9), local::DEHAZE_PATCH_MAX);
let p = lightcraft_raster::Plane::from_fn(9, 5, |x, y| if (x, y) == (4, 2) { 0.0 } else { 1.0 });
let m = local::min_hv(&p, 1);
assert_eq!(m.get(3, 1), 0.0);
assert_eq!(m.get(5, 3), 0.0);
assert_eq!(m.get(6, 2), 1.0);
assert_eq!(m.get(0, 0), 1.0);
}
#[test]
fn min_filter_matches_brute_force() {
for (w, h, r) in [(1, 1, 3), (7, 3, 1), (13, 11, 2), (40, 9, 5), (5, 30, 9)] {
let p = lightcraft_raster::Plane::from_fn(w, h, |x, y| ((x * 37 + y * 61) % 23) as f32);
let m = local::min_hv(&p, r);
for y in 0..h {
for x in 0..w {
let mut want = f32::INFINITY;
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
want = want.min(p.get(xx, yy));
}
}
assert_eq!(m.get(x, y), want, "{w}x{h} r {r} at ({x}, {y})");
}
}
}
}
+33
View File
@@ -488,3 +488,36 @@ fn level_degrees_sign_matches_crop_angle() {
let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found");
assert!((deg + 6.0).abs() < 0.5, "{deg}");
}
/// Rotate Right on a photo with a stored Upright correction: the corrected scene turns with the
/// photo (every source point lands where the turned frame puts it), from every orientation.
#[test]
fn a_turned_upright_correction_moves_with_the_photo() {
use lightcraft_develop::Upright;
use lightcraft_geom::{Homography, Orientation};
let (sw, sh) = (600usize, 400usize);
let h = Homography([1.02, -0.013, -0.006, 0.026, 1.021, -0.025, -0.004, -0.03, 1.0]);
for e in 1..=8 {
let o1 = Orientation::from_exif(e);
let o2 = o1.rotated(true);
let settings = |o: Orientation, t: Homography| {
let mut s = DevelopSettings { orientation: o, ..Default::default() };
s.geometry.upright = Upright::Auto;
s.geometry.upright_transform = Some(t);
s
};
let f1 = crate::geometry::Frame::new(sw, sh, &settings(o1, h), false);
let f2 = crate::geometry::Frame::new(sw, sh, &settings(o2, crate::upright::turned(&h, Orientation::Rotate90)), false);
let (w1, w2) = (f1.warp.as_ref().unwrap(), f2.warp.as_ref().unwrap());
for (x, y) in [(10.0, 20.0), (300.0, 200.0), (590.0, 15.0), (45.0, 390.0)] {
let at = |o: Orientation| {
let (u, v) = o.map(x, y, sw as f64, sh as f64);
Point::new(u, v)
};
let t1 = w1.from_corrected(at(o1));
let (u, v) = Orientation::Rotate90.map(t1.x, t1.y, f1.ow, f1.oh);
let t2 = w2.from_corrected(at(o2));
assert!(t2.dist(Point::new(u, v)) < 1e-6, "{o1:?}: {t2:?} vs ({u}, {v})");
}
}
}
+16 -1
View File
@@ -19,7 +19,7 @@ use std::borrow::Cow;
use std::sync::Mutex;
use lightcraft_develop::{Crop, DevelopSettings, Geometry, Upright};
use lightcraft_geom::{Homography, Point};
use lightcraft_geom::{Homography, Orientation, Point};
use lightcraft_raster::{Plane, Rgb32f};
use crate::SourceInfo;
@@ -595,6 +595,21 @@ pub fn resolve<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings) -> C
Cow::Owned(e)
}
/// A stored Upright correction (`Geometry::upright_transform`) for the photo turned a further
/// `turn` (Rotate Left / Right): the same correction of the same scene, in the turned frame. In
/// centred coordinates, which are isotropic and keep their scale (the long edge stays the long
/// edge), a flip is x → −x and a clockwise quarter turn (x, y) → (−y, x).
pub fn turned(h: &Homography, turn: Orientation) -> Homography {
let (flip, quarter_turns) = turn.to_parts();
let mut m = if flip { Homography([-1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]) } else { Homography::IDENTITY };
for _ in 0..quarter_turns {
m = Homography([0.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]).mul(&m);
}
// m is a signed permutation: its inverse is its transpose
let back = Homography([m.0[0], m.0[3], 0.0, m.0[1], m.0[4], 0.0, 0.0, 0.0, 1.0]);
m.mul(h).mul(&back)
}
/// The Upright homography in effect for settings (stored automatic transform, or solved from guides).
/// `ow × oh` is the oriented image size (guides are normalized to it).
pub fn homography(g: &Geometry, ow: f64, oh: f64) -> Homography {
+264 -4
View File
@@ -9,6 +9,11 @@
//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour
//! tint on a black-and-white image, the image on black / white, or the alpha as white on black
//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`].
//! - **Alt/Option-drag previews** (Lightroom's temporary views while a slider is dragged with the
//! modifier held; [`Overlay::for_control`] picks one per control): shadow / highlight clipping
//! (white or black, with the clipped channels shown), greyscale (Sharpening Amount), the fine
//! detail band sharpening works on (Radius / Detail) and the sharpening mask (Masking). The
//! sharpening views read the render's own detail planes ([`sharpen_plane`]).
use std::borrow::Cow;
@@ -36,6 +41,19 @@ pub enum Overlay {
/// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR
/// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]).
HdrRange,
/// Shadow clipping (Alt-drag on Blacks / Shadows): white, a channel at 0 drops out of the
/// colour, so pixels clipped in every channel are black and partly clipped ones are coloured.
ClipShadows,
/// Highlight clipping (Alt-drag on Exposure / Highlights / Whites): black, a channel at 255
/// shows in the colour, so pixels clipped in every channel are white.
ClipHighlights,
/// The photo in greyscale (Alt-drag on Sharpening Amount).
Grayscale,
/// The fine detail band sharpening amplifies, as grey ± detail (Alt-drag on Sharpening Radius
/// / Detail).
SharpenDetail,
/// The sharpening mask, white where sharpening applies (Alt-drag on Sharpening Masking).
SharpenMask,
}
/// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white.
@@ -121,6 +139,11 @@ impl Overlay {
Overlay::Spots(t) => (2, t as f64),
Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64),
Overlay::HdrRange => (4, 0.0),
Overlay::ClipShadows => (5, 0.0),
Overlay::ClipHighlights => (6, 0.0),
Overlay::Grayscale => (7, 0.0),
Overlay::SharpenDetail => (8, 0.0),
Overlay::SharpenMask => (9, 0.0),
}
}
@@ -131,6 +154,11 @@ impl Overlay {
2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8),
3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64),
4 => Overlay::HdrRange,
5 => Overlay::ClipShadows,
6 => Overlay::ClipHighlights,
7 => Overlay::Grayscale,
8 => Overlay::SharpenDetail,
9 => Overlay::SharpenMask,
_ => Overlay::None,
}
}
@@ -143,9 +171,44 @@ impl Overlay {
Overlay::Spots(t) => 0x2000 + t as u64,
Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity),
Overlay::HdrRange => 0x4000,
Overlay::ClipShadows => 0x5000,
Overlay::ClipHighlights => 0x5001,
Overlay::Grayscale => 0x5002,
Overlay::SharpenDetail => 0x5003,
Overlay::SharpenMask => 0x5004,
}
}
/// The temporary preview Lightroom shows while develop control `id` is dragged with Alt
/// (Option on macOS) held, if it has one.
pub fn for_control(id: &str) -> Option<Overlay> {
match id {
"light.exposure" | "light.highlights" | "light.whites" => Some(Overlay::ClipHighlights),
"light.shadows" | "light.blacks" => Some(Overlay::ClipShadows),
"detail.sharpenAmount" | "detail.nrLuminance" | "detail.nrDetail" | "detail.nrContrast" => Some(Overlay::Grayscale),
"detail.sharpenRadius" | "detail.sharpenDetail" => Some(Overlay::SharpenDetail),
"detail.sharpenMasking" => Some(Overlay::SharpenMask),
_ => None,
}
}
/// The clipping previews, which test the sRGB values the histogram counts (a render for a
/// display profile draws them in sRGB, then converts).
pub fn is_clipping(self) -> bool {
matches!(self, Overlay::ClipShadows | Overlay::ClipHighlights)
}
/// Whether the overlay draws from the render's detail planes ([`sharpen_plane`]).
pub fn reads_detail(self) -> bool {
matches!(self, Overlay::SharpenDetail | Overlay::SharpenMask)
}
/// Whether the overlay works pixel by pixel on the finished image, so a window render shows
/// the same as the whole frame would.
pub fn per_pixel(self) -> bool {
matches!(self, Overlay::None | Overlay::Mask { .. } | Overlay::ClipShadows | Overlay::ClipHighlights | Overlay::Grayscale)
}
/// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components).
pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> {
match self {
@@ -167,9 +230,44 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
}
}
/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask
/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]).
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) {
/// The plane a sharpening view draws ([`Overlay::reads_detail`]), from the render's log luminance
/// `log_l` and its texture blur (`None` when the render didn't need it: blurred here with the same
/// sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV);
/// [`Overlay::SharpenMask`]: the sharpening weight under the photo's Masking. Both renderers call
/// this with their own planes.
pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Option<&Plane>, px_per_long: f64) -> Option<Plane> {
if !o.reads_detail() {
return None;
}
let own;
let blur = match texture.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) {
Some(b) => b,
None => {
own = lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long));
&own
}
};
let masking = (s.detail.sharpen_masking / 100.0).clamp(0.0, 1.0) as f32;
let mask = o == Overlay::SharpenMask;
let data = log_l
.data
.iter()
.zip(&blur.data)
.map(|(l, b)| {
let det = l - b;
if mask { crate::finish::sharpen_mask_weight(masking, det) } else { det }
})
.collect();
Some(Plane { width: log_l.width, height: log_l.height, data })
}
/// [`Overlay::SharpenDetail`]: encoded grey per EV of detail (±0.4 EV spans black to white).
const DETAIL_GAIN: f32 = 320.0;
/// Draw overlay `o` over `img` (rendered with `plan`). `plane` is what the overlay shows, at the
/// image's size: the evaluated alpha of the mask an [`Overlay::Mask`] shows (see
/// [`Overlay::mask`]) or a sharpening view's [`sharpen_plane`].
pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, plane: Option<&Plane>) {
match o {
// drawn by the renderer from the HDR rendition ([`hdr_range`])
Overlay::None | Overlay::HdrRange => {}
@@ -180,13 +278,60 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>)
}
Overlay::Spots(t) => spots(img, t, plan.px_per_long),
Overlay::Mask { view, color, opacity, .. } => {
if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) {
if let Some(a) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) {
mask_view(img, a, view, color, opacity);
}
}
Overlay::ClipShadows => clipping(img, false),
Overlay::ClipHighlights => clipping(img, true),
Overlay::Grayscale => grayscale(img),
Overlay::SharpenDetail | Overlay::SharpenMask => {
if let Some(p) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) {
let detail = o == Overlay::SharpenDetail;
let w = img.width;
for_rows(&mut img.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let v = p.data[y * w + x];
let g = if detail { 128.0 + v * DETAIL_GAIN } else { 255.0 * v };
let g = g.round().clamp(0.0, 255.0) as u8;
*px = [g, g, g, 255];
}
});
}
}
}
}
/// [`Overlay::ClipHighlights`] (`high`) or [`Overlay::ClipShadows`]: each channel shows only
/// whether it is clipped, on black (highlights) or white (shadows).
pub fn clipping(img: &mut Rgba8, high: bool) {
let w = img.width;
for_rows(&mut img.data, w, |_, row| {
for px in row.iter_mut() {
for c in &mut px[..3] {
*c = if high {
if *c == 255 { 255 } else { 0 }
} else if *c == 0 {
0
} else {
255
};
}
px[3] = 255;
}
});
}
fn grayscale(img: &mut Rgba8) {
let w = img.width;
for_rows(&mut img.data, w, |_, row| {
for px in row.iter_mut() {
let g = (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32).round().clamp(0.0, 255.0) as u8;
*px = [g, g, g, 255];
}
});
}
/// Draw mask alpha `a` over `img` as `view`.
pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) {
let op = opacity.min(100) as f32 / 100.0;
@@ -380,4 +525,119 @@ mod tests {
assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color);
assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw));
}
#[test]
fn clipping_views_show_the_clipped_channels() {
let mut img = Rgba8 {
width: 5,
height: 1,
data: vec![[0, 0, 0, 255], [255, 255, 255, 255], [255, 40, 0, 255], [0, 128, 200, 255], [120, 130, 140, 255]],
};
let mut hi = img.clone();
clipping(&mut hi, true);
// black background, white where every channel clips, the clipped channels' colour otherwise
assert_eq!(hi.data, vec![[0, 0, 0, 255], [255; 4], [255, 0, 0, 255], [0, 0, 0, 255], [0, 0, 0, 255]]);
clipping(&mut img, false);
// white background, black where every channel clips, the unclipped channels' colour otherwise
assert_eq!(img.data, vec![[0, 0, 0, 255], [255; 4], [255, 255, 0, 255], [0, 255, 255, 255], [255; 4]]);
}
#[test]
fn clipping_previews_follow_the_rendered_clipping() {
// left: deep shadow, middle: grey, right: bright highlight
let src = Rgb32f::from_fn(60, 20, |x, _| match x / 20 {
0 => [0.0; 3],
1 => [0.18; 3],
_ => [3.0; 3],
});
let info = SourceInfo::default();
let mut s = DevelopSettings::default();
s.light.blacks = -100.0;
s.light.whites = 100.0;
let req = |o| RenderRequest { overlay: o, ..RenderRequest::fit(60, 20) };
let plain = render(&src, &info, &s, &req(Overlay::None));
let hi = render(&src, &info, &s, &req(Overlay::ClipHighlights));
let lo = render(&src, &info, &s, &req(Overlay::ClipShadows));
let at = |i: &Rgba8, x: usize| i.data[10 * 60 + x];
assert_eq!(at(&plain.image, 55), [255; 4], "the highlight clips");
assert_eq!(at(&plain.image, 5), [0, 0, 0, 255], "the shadow clips");
assert_eq!((at(&hi.image, 55), at(&hi.image, 30), at(&hi.image, 5)), ([255; 4], [0, 0, 0, 255], [0, 0, 0, 255]));
assert_eq!((at(&lo.image, 5), at(&lo.image, 30), at(&lo.image, 55)), ([0, 0, 0, 255], [255; 4], [255; 4]));
// a preview only: the histogram is the photo's
assert_eq!(hi.histogram, plain.histogram);
assert_eq!(render(&src, &info, &s, &req(Overlay::None)).image, plain.image);
}
#[test]
fn sharpening_previews_show_the_mask_and_detail() {
// a smooth ramp (left) and a hard edge (at x = 60)
let src = Rgb32f::from_fn(80, 40, |x, _| if x < 60 { [0.05 + 0.002 * x as f32; 3] } else { [0.6; 3] });
let info = SourceInfo::default();
let mut s = DevelopSettings::default();
s.detail.sharpen_amount = 60.0;
let shot = |s: &DevelopSettings, o| render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
let (flat, edge) = (20 * 80 + 20, 20 * 80 + 60);
// Masking 0 sharpens everything: an all-white mask
let m = shot(&s, Overlay::SharpenMask);
assert!(m.data.iter().all(|p| *p == [255; 4]));
// Masking 100 keeps the edge, protects the ramp
s.detail.sharpen_masking = 100.0;
let m = shot(&s, Overlay::SharpenMask);
assert_eq!((m.data[edge], m.data[flat]), ([255; 4], [0, 0, 0, 255]));
// the same without sharpening on (the render has no texture plane then)
s.detail.sharpen_amount = 0.0;
assert_eq!(shot(&s, Overlay::SharpenMask), m);
// detail: mid grey on the ramp, light / dark either side of the edge
let d = shot(&s, Overlay::SharpenDetail);
assert!(d.data[flat][0].abs_diff(128) <= 3, "{:?}", d.data[flat]);
assert!(d.data[edge][0] > 200 && d.data[edge - 1][0] < 60, "{:?} {:?}", d.data[edge], d.data[edge - 1]);
// greyscale
let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]);
let g = render(&src, &info, &s, &RenderRequest { overlay: Overlay::Grayscale, ..RenderRequest::fit(8, 8) }).image;
assert!(g.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0));
}
#[test]
fn alt_previews_map_to_controls_and_round_trip() {
assert_eq!(Overlay::for_control("light.blacks"), Some(Overlay::ClipShadows));
assert_eq!(Overlay::for_control("light.shadows"), Some(Overlay::ClipShadows));
assert_eq!(Overlay::for_control("light.whites"), Some(Overlay::ClipHighlights));
assert_eq!(Overlay::for_control("light.exposure"), Some(Overlay::ClipHighlights));
assert_eq!(Overlay::for_control("light.highlights"), Some(Overlay::ClipHighlights));
assert_eq!(Overlay::for_control("detail.sharpenAmount"), Some(Overlay::Grayscale));
assert_eq!(Overlay::for_control("detail.sharpenDetail"), Some(Overlay::SharpenDetail));
assert_eq!(Overlay::for_control("detail.sharpenMasking"), Some(Overlay::SharpenMask));
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] {
assert_eq!(Overlay::for_control(c), Some(Overlay::Grayscale), "{c}");
}
assert_eq!(Overlay::for_control("detail.nrColor"), None);
assert_eq!(Overlay::for_control("light.contrast"), None);
// every mapped id is a real control (a rename would silently drop its preview)
for c in [
"light.exposure",
"light.highlights",
"light.whites",
"light.shadows",
"light.blacks",
"detail.sharpenAmount",
"detail.sharpenRadius",
"detail.sharpenDetail",
"detail.sharpenMasking",
"detail.nrLuminance",
"detail.nrDetail",
"detail.nrContrast",
] {
assert!(lightcraft_develop::controls::find(c).is_some(), "{c}");
assert!(Overlay::for_control(c).is_some(), "{c}");
}
assert!(Overlay::ClipShadows.is_clipping() && Overlay::ClipHighlights.is_clipping() && !Overlay::Grayscale.is_clipping());
let all = [Overlay::ClipShadows, Overlay::ClipHighlights, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask];
for o in all {
let (k, v) = o.to_parts();
assert_eq!(Overlay::from_parts(k, v), o);
assert_eq!(all.iter().filter(|p| p.key() == o.key()).count(), 1);
assert_ne!(o.key(), Overlay::HdrRange.key());
}
assert!(Overlay::ClipShadows.per_pixel() && !Overlay::SharpenDetail.per_pixel());
}
}
+62 -25
View File
@@ -22,6 +22,9 @@ const QUALITY: u8 = 90;
pub struct DiskCache {
dir: PathBuf,
/// Whether a missing `dir` may be created (when its parent exists). Not for a folder the user
/// chose: see [`DiskCache::chosen`].
create_dir: bool,
/// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]).
budget: AtomicU64,
/// Bytes on disk (`None` until first scanned).
@@ -35,6 +38,7 @@ impl DiskCache {
pub fn new(dir: &Path, budget: u64) -> DiskCache {
DiskCache {
dir: dir.to_path_buf(),
create_dir: true,
budget: AtomicU64::new(budget),
total: Mutex::new(None),
hits: AtomicU64::new(0),
@@ -43,6 +47,12 @@ impl DiskCache {
}
}
/// A cache in a folder the user chose (on another drive, perhaps): it is never created, so
/// while it is missing (drive unplugged) nothing is written and every read misses.
pub fn chosen(dir: &Path, budget: u64) -> DiskCache {
DiskCache { create_dir: false, ..DiskCache::new(dir, budget) }
}
pub fn dir(&self) -> &Path {
&self.dir
}
@@ -91,7 +101,7 @@ impl DiskCache {
let Some(bytes) = encode_jpeg(img) else { return };
let p = self.path(key);
let Some(parent) = p.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
if !parent.is_dir() && !self.make_shard(parent) {
return;
}
// Serialize the file replacement and its size accounting. A repeated render of
@@ -113,33 +123,26 @@ impl DiskCache {
}
}
/// All cache files (only names of the cache's own shape, see [`is_cache_file`]):
/// (path, size, modified).
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.dir) else { return out };
for sub in rd.flatten() {
let sub_name = sub.file_name();
let Some(sub_name) = sub_name.to_str() else { continue };
if !is_shard_name(sub_name) {
continue;
/// Create the shard directory `shard` for a write, and the cache directory itself when its
/// parent exists and it wasn't [`DiskCache::chosen`]. Never creates missing parents: a cache
/// folder on an unplugged drive is not silently recreated on the system drive (its mount
/// point), the write is just skipped.
fn make_shard(&self, shard: &Path) -> bool {
if !self.dir.is_dir() {
if !self.create_dir || !self.dir.parent().is_some_and(Path::is_dir) {
return false;
}
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
let name = f.file_name();
let Some(name) = name.to_str() else { continue };
if !is_cache_file(sub_name, name) {
continue;
}
// `DirEntry::metadata` does not follow symlinks: a link is never a cache file
if let Ok(m) = f.metadata()
&& m.is_file()
{
out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
}
// another writer may have just made it
if std::fs::create_dir(&self.dir).is_err() && !self.dir.is_dir() {
return false;
}
}
out
std::fs::create_dir(shard).is_ok() || shard.is_dir()
}
/// All cache files: see [`cache_files`].
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
cache_files(&self.dir)
}
/// Remove least recently used files until the cache is at most 80 % of its budget.
@@ -173,6 +176,40 @@ impl DiskCache {
}
}
/// All files of a thumbnail cache in `dir` (only names of the cache's own shape, see
/// [`is_cache_file`]; symlinks are never followed): (path, size, modified).
pub fn cache_files(dir: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for sub in rd.flatten() {
let sub_name = sub.file_name();
let Some(sub_name) = sub_name.to_str() else { continue };
if !is_shard_name(sub_name) {
continue;
}
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
let name = f.file_name();
let Some(name) = name.to_str() else { continue };
if !is_cache_file(sub_name, name) {
continue;
}
// `DirEntry::metadata` does not follow symlinks: a link is never a cache file
if let Ok(m) = f.metadata()
&& m.is_file()
{
out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
}
}
}
out
}
/// The number of cached thumbnails in `dir` and their total size in bytes.
pub fn cache_stats(dir: &Path) -> (usize, u64) {
cache_files(dir).iter().fold((0usize, 0u64), |(n, bytes), f| (n.saturating_add(1), bytes.saturating_add(f.1)))
}
fn is_lower_hex(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
+6 -1
View File
@@ -42,7 +42,12 @@ impl PreviewCache {
/// Memory LRU + disk cache in `dir` (at most `disk_bytes`).
pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache {
PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) }
PreviewCache::with_disk_cache(mem_bytes, DiskCache::new(dir, disk_bytes))
}
/// Memory LRU + the given disk cache (e.g. a [`DiskCache::chosen`] one).
pub fn with_disk_cache(mem_bytes: usize, disk: DiskCache) -> PreviewCache {
PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(disk) }
}
pub fn disk(&self) -> Option<&DiskCache> {
+26
View File
@@ -123,6 +123,32 @@ fn disk_cache_roundtrip_and_prune() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A missing cache folder is made when its parent exists, never with missing parents; a folder
/// the user chose is never made again (issue #903: an unplugged drive's folder stays missing).
#[test]
fn disk_cache_creates_only_its_own_missing_folder() {
let root = temp_dir("mkdir");
std::fs::create_dir_all(&root).unwrap();
let img = gradient(16, 16, 3);
let k = hash_bytes(b"mk");
let own = root.join("thumbs");
DiskCache::new(&own, 1 << 20).put(k, &img);
assert!(DiskCache::new(&own, 1 << 20).get(k).is_some(), "the library's own folder is made");
let deep = root.join("unplugged/thumbs");
DiskCache::new(&deep, 1 << 20).put(k, &img);
assert!(!root.join("unplugged").exists(), "no missing parents");
let chosen = root.join("chosen");
let d = DiskCache::chosen(&chosen, 1 << 20);
d.put(k, &img);
assert!(!chosen.exists(), "a chosen folder is not made");
assert!(d.get(k).is_none());
std::fs::create_dir(&chosen).unwrap();
d.put(k, &img);
assert!(d.get(k).is_some(), "written once it is back");
assert_eq!(super::disk::cache_stats(&chosen).0, 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() {
let dir = temp_dir("overwrite");
+1 -2
View File
@@ -64,8 +64,7 @@ impl RawImage {
// Opcode lists 1/2 need the normalised copy; plain files are normalised row by row.
let norm: Option<Normalized> = if self.opcodes.list1.is_empty() && self.opcodes.list2.is_empty() { None } else { Some(self.normalized()?) };
let cfa = cfa.shifted(a.x, a.y);
let range = self.white_at(0) - self.black.mean();
let scale = if range > 0.0 { 1.0 / range } else { 1.0 };
let scale = self.normalization_scales().first().copied().unwrap_or(1.0);
let uniform_black =
self.black.delta_h.is_empty() && self.black.delta_v.is_empty() && self.black.values.iter().all(|&v| v == self.black.values[0]);
let black0 = self.black.at(0, 0, 0, 1);
+82 -9
View File
@@ -101,7 +101,7 @@ pub enum RawFormat {
Srw,
/// Canon CRW (CIFF heap), the bodies before the CR2 era.
Crw,
/// Minolta MRW (`\0MRM` block container).
/// Minolta MRW (`\0MRM` block container, `vendor::mrw`).
Mrw,
/// Sigma / Foveon X3F (`FOVb`).
X3f,
@@ -132,6 +132,7 @@ impl RawFormat {
| RawFormat::CfaTiff
| RawFormat::Srw
| RawFormat::KodakDcs
| RawFormat::Mrw
)
}
}
@@ -385,6 +386,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped),
RawFormat::Srw => vendor::srw::decode(bytes, mode),
RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode),
RawFormat::Mrw => vendor::mrw::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
}
@@ -581,6 +583,35 @@ impl BlackLevel {
let base = self.values.get(idx).or_else(|| self.values.first()).copied().unwrap_or(0.0);
base + self.delta_h.get(x).copied().unwrap_or(0.0) + self.delta_v.get(y).copied().unwrap_or(0.0)
}
/// Maximum computed black of one sample plane over the active area (DNG 1.7.1, p. 99).
/// Delta maxima must share the repeat-pattern phase of the base value: independent global
/// maxima can describe a black level that occurs nowhere in the image.
fn maximum(&self, width: usize, height: usize, s: usize, cpp: usize) -> f32 {
fn phase_maxima(deltas: &[f32], len: usize, period: usize) -> Vec<f32> {
if deltas.is_empty() {
return vec![0.0; len.min(period)];
}
let mut maxima = vec![f32::NEG_INFINITY; len.min(period)];
for i in 0..len {
if let Some(v) = maxima.get_mut(i % period) {
*v = v.max(deltas.get(i).copied().unwrap_or(0.0));
}
}
maxima
}
let (r, c) = (self.repeat_rows.max(1), self.repeat_cols.max(1));
let dx = phase_maxima(&self.delta_h, width, c);
let dy = phase_maxima(&self.delta_v, height, r);
let mut max = f32::NEG_INFINITY;
for (y, &v) in dy.iter().enumerate() {
for (x, &h) in dx.iter().enumerate() {
let idx = y.saturating_mul(c).saturating_add(x).saturating_mul(cpp).saturating_add(s);
let base = self.values.get(idx).or_else(|| self.values.first()).copied().unwrap_or(0.0);
max = max.max(base + h + v);
}
}
max
}
/// Mean of the repeat-pattern values (for display / heuristics).
pub fn mean(&self) -> f32 {
if self.values.is_empty() { 0.0 } else { self.values.iter().sum::<f32>() / self.values.len() as f32 }
@@ -735,6 +766,19 @@ impl RawImage {
self.white.get(s).or_else(|| self.white.first()).copied().unwrap_or(65535.0)
}
/// Shared by full-resolution and binned development. DNG's scale uses the maximum computed
/// black independently for each sample plane; vendor formats keep their existing scaling.
pub(crate) fn normalization_scales(&self) -> Vec<f32> {
let dng = matches!(self.format, RawFormat::Dng | RawFormat::CfaTiff);
(0..self.cpp)
.map(|s| {
let black = if dng { self.black.maximum(self.active_area.width, self.active_area.height, s, self.cpp) } else { self.black.mean() };
let range = self.white_at(s) - black;
if range.is_finite() && range > 0.0 && range.recip().is_finite() { range.recip() } else { 1.0 }
})
.collect()
}
/// Black-subtract, white-scale and crop to the active area, applying `OpcodeList1` (on raw values) first and
/// `OpcodeList2` (on normalised values) after. 1.0 = white level.
pub fn normalized(&self) -> Result<Normalized> {
@@ -753,12 +797,7 @@ impl RawImage {
Some(v) => v[i],
None => self.data.get(i),
};
let scale: Vec<f32> = (0..cpp)
.map(|s| {
let range = self.white_at(s) - self.black.mean();
if range > 0.0 { 1.0 / range } else { 1.0 }
})
.collect();
let scale = self.normalization_scales();
let mut out = vec![0f32; a.width * a.height * cpp];
let row_len = a.width * cpp;
use rayon::prelude::*;
@@ -1021,10 +1060,10 @@ mod tests {
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] {
for f in [RawFormat::Crw, RawFormat::X3f] {
assert!(!f.is_supported());
}
assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f")));
// too short for the CIFF signature
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
}
@@ -1301,6 +1340,40 @@ mod tests {
assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None);
}
#[test]
fn normalization_scales_fall_back_for_empty_or_non_finite_ranges() {
let mut raw = RawImage {
format: RawFormat::Dng,
width: 1,
height: 1,
cpp: 1,
data: RawData::U16(vec![0]),
cfa: None,
bits: 16,
black: BlackLevel::default(),
white: vec![1024.0],
active_area: Rect::new(0, 0, 1, 1),
crop: Rect::new(0, 0, 1, 1),
orientation: Orientation::Rotate90,
color: ColorData::default(),
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
};
assert_eq!(raw.normalization_scales(), vec![1.0 / 1024.0]);
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
raw.active_area = Rect::new(0, 0, width, height);
assert_eq!(raw.normalization_scales(), vec![1.0], "empty area {width}×{height}");
assert!(raw.normalized().is_err());
}
raw.active_area = Rect::new(0, 0, 1, 1);
for white in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 0.0, f32::from_bits(1)] {
raw.white = vec![white];
assert_eq!(raw.normalization_scales(), vec![1.0], "white {white}");
}
}
#[test]
fn probe_rejects_junk() {
assert_eq!(probe(b""), None);
+2 -1
View File
@@ -616,7 +616,8 @@ mod tests {
f.extend_from_slice(part);
f.extend_from_slice(&[0u8; 7]);
}
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}");
// MRW decodes; its preview is still found by the scan
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}");
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
for n in (0..f.len()).step_by(11) {
let _ = embedded_preview(&f[..n]);
+188 -16
View File
@@ -12,8 +12,10 @@
//! start of the first one, where the coding headers a header probe validates sit), a CR3's image
//! tracks (keeping the raw track's coding headers and quantization maps, and the timed-metadata
//! track where Canon puts the as-shot white balance), a RAF's CFA strip (keeping its compression
//! header) and a Panasonic RW2's raw data. Formats whose header probe reads samples (NEF: masked
//! columns; ORF: bit depth and CFA phase; PEF: dark borders) and anything else get
//! header) and a Panasonic RW2's raw data. A Sony ARW6's strip is not a prefix but the scattered
//! bytes its header probe validates (the tile table, the stream headers and index tables, the
//! residual stream's first half: see [`arw6_keep`]). Formats whose header probe reads samples (NEF:
//! masked columns; ORF: bit depth and CFA phase; PEF: dark borders) and anything else get
//! [`HeaderPlan::Whole`], as does a structure that is incomplete, inconsistent or keeps metadata
//! inside its sample data: the plan never guesses.
@@ -160,7 +162,7 @@ pub fn preview_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan {
fn plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
let mut w = Want::new(bytes, loaded);
if !w.want(0, 16) {
return w.into_plan().unwrap_or(HeaderPlan::Whole("file too short"));
return w.pending_plan().unwrap_or(HeaderPlan::Whole("file too short"));
}
if is_cr3(bytes) {
cr3_plan(bytes, loaded, preview)
@@ -200,9 +202,9 @@ impl<'a> Want<'a> {
false
}
/// `Need` when something is missing.
fn into_plan(self) -> Option<HeaderPlan> {
(!self.need.is_empty()).then_some(HeaderPlan::Need(self.need))
/// `Need` when something is missing (the asks are taken).
fn pending_plan(&mut self) -> Option<HeaderPlan> {
(!self.need.is_empty()).then(|| HeaderPlan::Need(std::mem::take(&mut self.need)))
}
}
@@ -223,7 +225,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
Ok(meta) => meta,
Err(why) => return HeaderPlan::Whole(why),
};
if let Some(plan) = w.into_plan() {
if let Some(plan) = w.pending_plan() {
return plan;
}
// (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read)
@@ -233,20 +235,60 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") };
let mut holes = Ranges::new();
for ifd in tiff.all_ifds() {
let chunks: Vec<(u64, u64)> = match ifd.image() {
let info = ifd.image().ok();
// A JPEG-interchange image (compression 6/7: tags 0x0201/0x0202 are `JPEGInterchangeFormat` and
// `JPEGInterchangeFormatLength`, not strip tags, which is why `image()` declines; e.g. a Sony
// ARW's full-size JPEG): the header probe never reads it and `embedded_preview` wants it whole,
// so under [`header_plan`] its bulk is holed like any other image's.
let jif = (|| {
if preview || !matches!(ifd.u16(t::COMPRESSION), Some(6 | 7)) {
return None;
}
let off = ifd.u64(t::JPEG_INTERCHANGE_FORMAT)?;
let jif_len = ifd.u64(t::JPEG_INTERCHANGE_FORMAT_LENGTH)?;
(jif_len >= BULK).then_some((off, jif_len))
})();
let chunks: Vec<(u64, u64)> = match &info {
// a preview's strips are read when the preview is wanted: only the raw image is skipped
Ok(info) if preview && !matches!(info.photometric, t::photometric::CFA | t::photometric::LINEAR_RAW) => continue,
Ok(info) => info.chunks(len).iter().map(|c| (c.offset, c.len)).collect(),
Some(info) if preview && !matches!(info.photometric, t::photometric::CFA | t::photometric::LINEAR_RAW) => continue,
Some(info) => info.chunks(len).iter().map(|c| (c.offset, c.len)).collect(),
// a raw IFD without the tags a TIFF image needs (an older CR2's) still lists its strips
Err(_) => match (ifd.u64s(t::STRIP_OFFSETS), ifd.u64s(t::STRIP_BYTE_COUNTS)) {
None => match (ifd.u64s(t::STRIP_OFFSETS), ifd.u64s(t::STRIP_BYTE_COUNTS)) {
(Some(offsets), Some(counts)) if offsets.len() == counts.len() => offsets.into_iter().zip(counts).collect(),
_ => continue,
// nothing to hole but for a JPEG-interchange image (see `jif`)
_ => {
if let Some((off, jif_len)) = jif {
holes.insert(off.saturating_add(HEAD), off.saturating_add(jif_len).min(len));
}
continue;
}
},
};
let total = chunks.iter().fold(0u64, |n, c| n.saturating_add(c.1));
if total < BULK {
continue;
}
// A compression-32766 (ARW6) strip: its header probe reads scattered bytes (the tile table, stream
// headers, index tables, the residual's first half), not a prefix — keep exactly those and hole the
// rest ([`arw6_keep`]). Multiple strips or a layout it cannot walk fail the probe identically on the
// whole file, so they stay here for the generic prefix hole or fall to `Whole`.
if let Some(info) = &info
&& info.compression == crate::vendor::arw6::COMPRESSION
&& let Some(&(chunk_offset, chunk_size)) = chunks.first()
{
match arw6_keep(&mut w, bytes, (chunk_offset, chunk_size), info.width as usize, info.height as usize) {
None => return w.pending_plan().unwrap_or(HeaderPlan::Whole("ARW6 strip")),
Some(Ok(keep)) => {
let mut full = Ranges::new();
full.insert(chunk_offset, chunk_offset.saturating_add(chunk_size).min(len));
for &(a, b) in full.minus(&keep).spans() {
holes.insert(a, b);
}
continue;
}
Some(Err(_)) => return HeaderPlan::Whole("ARW6 strip"),
}
}
for (i, &(offset, size)) in chunks.iter().enumerate() {
let start = if i == 0 { offset.saturating_add(HEAD) } else { offset };
holes.insert(start, offset.saturating_add(size).min(len));
@@ -408,7 +450,7 @@ fn cr3_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
}
at = end;
}
if let Some(plan) = w.into_plan() {
if let Some(plan) = w.pending_plan() {
return plan;
}
let mut w = Want::new(bytes, loaded);
@@ -434,7 +476,7 @@ fn cr3_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
}
}
}
w.into_plan().unwrap_or(HeaderPlan::Ready)
w.pending_plan().unwrap_or(HeaderPlan::Ready)
}
/// A RAF is read whole but for its CFA strip: the header, the embedded JPEG (its metadata), the
@@ -443,7 +485,7 @@ fn raf_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan {
let len = bytes.len() as u64;
let mut w = Want::new(bytes, loaded);
if !w.want(0, 160) {
return w.into_plan().unwrap_or(HeaderPlan::Whole("RAF header"));
return w.pending_plan().unwrap_or(HeaderPlan::Whole("RAF header"));
}
let (Some(start), Some(size)) = (be32(bytes, 100), be32(bytes, 104)) else { return HeaderPlan::Whole("RAF header") };
let Some(end) = start.checked_add(size).filter(|&e| start > 0 && size > 0 && e <= len) else { return HeaderPlan::Whole("RAF raw container") };
@@ -452,7 +494,7 @@ fn raf_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan {
Ok(meta) => meta,
Err(why) => return HeaderPlan::Whole(why),
};
if let Some(plan) = w.into_plan() {
if let Some(plan) = w.pending_plan() {
return plan;
}
let Ok(ifd) = crate::vendor::raf::raw_ifd(container) else { return HeaderPlan::Whole("RAF raw IFD") };
@@ -465,6 +507,105 @@ fn raf_plan(bytes: &[u8], loaded: &Ranges) -> HeaderPlan {
finish(len, loaded, &holes, &meta, || HeaderPlan::Ready)
}
/// Ask for several ranges in one go, keeping the loaded ones; false when any is still missing.
fn load_all(w: &mut Want, keep: &mut Ranges, spans: &[(u64, u64)]) -> bool {
let mut all = true;
for &(start, end) in spans {
let end = end.min(w.len);
if w.want(start, end) {
keep.insert(start, end);
} else {
all = false;
}
}
all
}
/// A compression-32766 (ARW6) strip's byte ranges that [`crate::vendor::arw6::decode`] reads in header mode — *not*
/// a prefix like the other codecs (`docs/arw6-compression.md`): the tile table, and per tile the picture and group
/// tables, the 13 stream header words, every stream's index table and the residual stream's first half, whose
/// lines fix the first-half shift. The later ranges' positions are only known once earlier ones are loaded, so the
/// walk runs in stages that ask for what they can in one round (`None` while something is still missing; its asks
/// wait in `w`); `Err` when the layout cannot be walked at all — the probe then stops at the same validation of
/// loaded bytes on the whole file, so the caller reads the file whole.
fn arw6_keep(w: &mut Want, bytes: &[u8], strip: (u64, u64), width: usize, height: usize) -> Option<Result<Ranges, &'static str>> {
use crate::vendor::arw6;
let (offset, size) = strip;
let strip_end = offset.saturating_add(size).min(bytes.len() as u64);
// the tile table: a count and at most sixteen 24-byte entries
if !w.want(offset, offset.saturating_add(8 + 24 * arw6::MAX_TILES as u64).min(strip_end)) {
return None;
}
let (at, end) = match (usize::try_from(offset), usize::try_from(strip_end)) {
(Ok(at), Ok(end)) if at <= end => (at, end),
_ => return Some(Err("ARW6 strip")),
};
let Some(strip) = bytes.get(at..end) else { return Some(Err("ARW6 strip")) };
let Ok(tiles) = arw6::parse_tile_table(strip, width, height) else { return Some(Err("ARW6 tile table")) };
// each tile's bytes run to the next tile's offset (the last: the strip end), as `tile_bytes` does: the
// `end_rel(i)` are positions in the strip and `abs` makes one absolute
let end_rel = |i: usize| -> usize {
match tiles.get(i + 1) {
Some(next) => next.offset,
None => strip.len(),
}
};
let abs = |rel: usize| offset.saturating_add(rel as u64);
// stage A: every tile's picture header (word 0) and group tables (words 3..8)
let mut keep = Ranges::new();
keep.insert(offset, offset.saturating_add(8 + 24 * tiles.len() as u64).min(strip_end));
let tables: Vec<(u64, u64)> = tiles.iter().enumerate().map(|(i, entry)| (abs(entry.offset), abs((entry.offset + 128).min(end_rel(i))))).collect();
if !load_all(w, &mut keep, &tables) {
return None;
}
// stage B: the 13 stream header words, at positions the group tables alone imply
let mut spans = Vec::with_capacity(tiles.len() * 13);
for (i, entry) in tiles.iter().enumerate() {
let Some(found) = strip.get(entry.offset..(entry.offset + 128).min(end_rel(i))).and_then(arw6::stream_header_positions) else {
return Some(Err("ARW6 tile header"));
};
let start = abs(entry.offset);
for p in found {
let at = start.saturating_add(p as u64 * 16);
spans.push((at, at.saturating_add(16)));
}
}
if !load_all(w, &mut keep, &spans) {
return None;
}
// stage C: with the header words loaded, the probe's own parse places each stream's index table
let mut headers = Vec::with_capacity(tiles.len());
let mut spans = Vec::with_capacity(tiles.len() * 13);
for (i, entry) in tiles.iter().enumerate() {
let Some(tile) = strip.get(entry.offset..end_rel(i)) else { return Some(Err("ARW6 tile header")) };
let Ok(header) = arw6::parse_tile_header(tile, entry.width, entry.height) else { return Some(Err("ARW6 tile header")) };
let start = abs(entry.offset);
for st in &header.streams {
let from = start.saturating_add((st.word as u64 + 1) * 16);
spans.push((from, from.saturating_add(st.index_words as u64 * 16)));
}
headers.push(header);
}
if !load_all(w, &mut keep, &spans) {
return None;
}
// stage D: each residual stream's first half — its first index entry (loaded with the index table) gives
// the half's byte length, and [`arw6::first_half_shift`] decodes its lines for the anchor offset
let mut spans = Vec::with_capacity(tiles.len());
for (i, entry) in tiles.iter().enumerate() {
let Some(tile) = strip.get(entry.offset..end_rel(i)) else { return Some(Err("ARW6 tile header")) };
let Some(header) = headers.get(i) else { return Some(Err("ARW6 tile header")) };
let Some(g4) = header.streams.get(12) else { return Some(Err("ARW6 tile header")) };
let Some(a) = arw6::u16_be(tile, (g4.word + 1) * 16) else { return Some(Err("ARW6 residual half")) };
let data = abs(entry.offset).saturating_add((g4.word as u64 + 1) * 16).saturating_add(g4.index_words as u64 * 16);
spans.push((data, data.saturating_add(a as u64).min(abs(end_rel(i)))));
}
if !load_all(w, &mut keep, &spans) {
return None;
}
Some(Ok(keep))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -550,4 +691,35 @@ mod tests {
raf[104..108].copy_from_slice(&1000u32.to_be_bytes());
assert_eq!(run(&raf, 160, 0).0, HeaderPlan::Whole("RAF raw container"));
}
/// An ARW6 strip's probe reads are not a prefix: the residual stream — whose first half fixes the first-half
/// shift — starts behind other streams' data (the corpus's `arw6-sony-ilce7rm6-apsc-land-hq.arw` used to be
/// described from zeros there and failed with "first-half row count outside 2..=7"). The plan keeps those
/// bytes, wherever in the strip they sit.
#[test]
fn arw6_plan_keeps_the_scattered_probe_reads() {
use crate::vendor::arw6_testenc::{Qis, arw6_file, one_tile_quantised};
for file in [
// one tile, like the ILCE-7RM6's APS-C crop
arw6_file(&[(0, 0, 1024, 1024, one_tile_quantised(1024, 1024, 3, Qis::REAL).0)], 1024, 1024),
// several tiles, like the full frame (each tile's reads are kept)
arw6_file(
&[
(0, 0, 1024, 1024, one_tile_quantised(1024, 1024, 0, Qis::REAL).0),
(1024, 0, 1024, 1024, one_tile_quantised(1024, 1024, 0, Qis::REAL).0),
],
2048,
1024,
),
] {
for fill in [0u8, 0xff] {
let (plan, buf, loaded) = run(&file, 512 * 1024, fill);
assert!(matches!(plan, HeaderPlan::Ready), "{plan:?} (fill {fill})");
// the plan asked for bytes past the prefix keep: the probe's reads off a prefix (zeros or 0xFF)
// would break the first-half shift, the regression above
assert!(loaded.spans().iter().any(|&(a, _)| a >= 512 * 1024), "nothing read past the prefix (fill {fill})");
assert_eq!(format!("{:?}", crate::probe_info(&buf)), format!("{:?}", crate::probe_info(&file)), "fill {fill}");
}
}
}
}
+26 -3
View File
@@ -20,12 +20,12 @@ fn unsupported(why: &str) -> RawError {
RawError::Unsupported(format!("ARW6 {why}"))
}
fn u16_be(b: &[u8], at: usize) -> Option<usize> {
pub(crate) fn u16_be(b: &[u8], at: usize) -> Option<usize> {
let s = b.get(at..at.checked_add(2)?)?;
Some(usize::from(u16::from_be_bytes([*s.first()?, *s.get(1)?])))
}
fn u24_be(b: &[u8], at: usize) -> Option<usize> {
pub(crate) fn u24_be(b: &[u8], at: usize) -> Option<usize> {
let s = b.get(at..at.checked_add(3)?)?;
Some(s.iter().fold(0usize, |a, &v| (a << 8) | usize::from(v)))
}
@@ -46,7 +46,10 @@ pub(crate) struct TileEntry {
}
/// Most tiles a strip may hold (real files have 1 or 4).
const MAX_TILES: usize = 16;
pub(crate) const MAX_TILES: usize = 16;
/// TIFF `Compression` of ARW6: Sony's "Compressed RAW 2" (SMPTE RDD 34), written by the ILCE-7RM6 and ILCE-7M5.
pub(crate) const COMPRESSION: u16 = 32766;
/// Parse the tile table at the start of the strip (file structure, *Container*): `u32` count, `u32` 0, then
/// 24-byte entries `u64` offset (relative to the strip), `u32` x, y, width, height, all little-endian.
@@ -171,6 +174,26 @@ pub(crate) fn parse_tile_header(tile: &[u8], tw: usize, th: usize) -> Result<Til
Ok(TileHeader { hs, vs, streams })
}
/// Word positions of a tile's 13 stream header words, from the group tables alone (bytes 48..128: per group a count
/// byte then `u24` stream sizes). [`crate::sparse`] loads those words before running [`parse_tile_header`] on a
/// partly loaded buffer; the two walks must agree.
pub(crate) fn stream_header_positions(tables: &[u8]) -> Option<[usize; 13]> {
let mut out = [0usize; 13];
let (mut word, mut i) = (8usize, 0usize);
for (group, &n) in GROUP_STREAMS.iter().enumerate() {
let base = (3 + group) * 16;
if usize::from(*tables.get(base)?) != n {
return None;
}
for comp in 0..n {
out[i] = word;
i += 1;
word = word.checked_add(u24_be(tables, base + 1 + 3 * comp)?)?;
}
}
Some(out)
}
/// One half-TU of a stream: `len` bytes at byte `start` of the tile, quantiser index per band
/// (g0/g4: the single nibble repeated).
#[derive(Clone, Debug, PartialEq, Eq)]
+47 -5
View File
@@ -11,13 +11,13 @@ const DISTORTION: u16 = 0x7037;
const SAMPLES: usize = 1024;
const MAX_ERROR: f64 = 0.0005;
/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts
/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md.
/// Only independently validated camera models (ILCE-7RM4A, ILCE-7M3) and the 16-entry layout.
/// Unknown models/layouts and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md.
/// A camera setting of Off does not remove the lens data: the user can enable it in the developer.
pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> {
// A matching table shape does not establish its scale or radial normalization on another body.
// Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases.
if model != "ILCE-7RM4A" {
if !matches!(model, "ILCE-7RM4A" | "ILCE-7M3") {
return None;
}
let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None };
@@ -147,6 +147,47 @@ mod tests {
}
}
// ILCE-7M3 raw-IFD tables (FE 16-35mm F4 ZA OSS at 16 and 35 mm, FE 28-70mm F3.5-5.6 OSS at 70 mm) and the
// geometry of the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto): checkerboard corners of
// that JPEG matched with the uncorrected raw and fitted with the warp's polynomial over the 6000×4000 frame (rms
// 0.11–0.25 px). See docs/sony-lens-corrections.md.
const A7M3: [([i16; 16], [f64; 4]); 3] = [
(
[32, 2, -38, -83, -138, -199, -270, -336, -402, -462, -519, -568, -611, -643, -670, -690],
[1.0247000859, -0.1092847508, 0.1061422243, -0.0397989678],
),
([2, 2, 4, 9, 18, 29, 45, 65, 92, 124, 164, 210, 264, 329, 403, 489], [0.9708150714, 0.0157256288, 0.0175613359, -0.004107544]),
([4, 7, 18, 40, 68, 108, 155, 212, 280, 359, 453, 557, 679, 819, 978, 1161], [0.9342002611, 0.0560486347, 0.0076184603, 0.0021497933]),
];
#[test]
fn ilce_7m3_matches_the_cameras_own_jpeg_geometry() {
let frame = Rect::new(0, 0, 6000, 4000);
let evaluate = |k: &[f64], r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3])));
for (values, reference) in A7M3 {
let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7M3", &table(&values), frame, frame).unwrap() else { panic!("warp") };
assert_eq!(center, [0.5, 0.5]);
// The camera fills its frame with its own zoom; compare the shape over the measured radii (to 0.97).
let radii: Vec<f64> = (1..=97).map(|i| i as f64 / 100.0).collect();
let (ours, camera): (Vec<f64>, Vec<f64>) = radii.iter().map(|&r| (evaluate(&planes[0], r), evaluate(&reference, r))).unzip();
let worst = |ours: &[f64]| {
let zoom = ours.iter().zip(&camera).map(|(a, b)| a * b).sum::<f64>() / ours.iter().map(|a| a * a).sum::<f64>();
ours.iter().zip(&camera).map(|(a, b)| (a * zoom - b).abs()).fold(0.0, f64::max)
};
// 0.0008 of the half diagonal is 2.9 px on the 6000 px frame (measured: 0.4–2.4 px); uncorrected is 10×+ off.
let (corrected, uncorrected) = (worst(&ours), worst(&radii));
assert!(corrected < 0.0008 && uncorrected > 10.0 * corrected, "{values:?}: {corrected} vs {uncorrected}");
}
// Sony's crop and the default crop agree on this body (12, 12, 6000×4000 of 6048×4024), so the warp sits on
// the image centre.
let Opcode::WarpRectilinear { center, .. } =
distortion("ILCE-7M3", &table(&A7M3[0].0), Rect::new(0, 0, 6048, 4024), Rect::new(12, 12, 6000, 4000)).unwrap()
else {
panic!("warp")
};
assert!((center[0] * 6047.0 - 3011.5).abs() < 1e-9 && (center[1] * 4023.0 - 2011.5).abs() < 1e-9, "{center:?}");
}
#[test]
fn barrel_framing_and_offset_crop_keep_the_same_geometry() {
let crop = Rect::new(32, 20, 9504, 6336);
@@ -185,7 +226,8 @@ mod tests {
fn refuses_unvalidated_models_even_with_a_valid_table() {
let valid = table(&TELE);
assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some());
for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] {
assert!(distortion("ILCE-7M3", &valid, AREA, AREA).is_some());
for model in ["", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7M3 ", "ILCE-7SM3"] {
assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}");
}
}
@@ -193,7 +235,7 @@ mod tests {
#[test]
fn header_and_full_decode_preserve_correction_through_dng() {
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t};
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] {
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 1), ("ILCE-7M4", 0), ("", 0)] {
for (height, expected) in [(20, expected), (16, 0)] {
for order in [ByteOrder::Little, ByteOrder::Big] {
let mut raw = IfdBuilder::new();
+1
View File
@@ -11,6 +11,7 @@ pub mod cr3;
pub mod crx;
pub(crate) mod crx_wavelet;
pub mod kodak;
pub mod mrw;
pub mod nef;
pub mod nefc;
pub mod orf;
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more