Compare commits
332
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377fbedbb5 | ||
|
|
88f1330483 | ||
|
|
1bbe92214c | ||
|
|
acb4488f18 | ||
|
|
5830304692 | ||
|
|
63a123236f | ||
|
|
75feedcae0 | ||
|
|
859d9492f9 | ||
|
|
aedd3cbbb8 | ||
|
|
1d02ae775a | ||
|
|
bc2c44676f | ||
|
|
328bdabe66 | ||
|
|
7d331761de | ||
|
|
951e59f7cc | ||
|
|
92093461e0 | ||
|
|
2020ad1df4 | ||
|
|
923a620d5a | ||
|
|
97d38486b7 | ||
|
|
395df0429a | ||
|
|
d18c8454b1 | ||
|
|
ca5292f883 | ||
|
|
e6d4ea7c60 | ||
|
|
2f5ba5e69a | ||
|
|
a92066c47c | ||
|
|
ac40fa3882 | ||
|
|
5f81dfcdde | ||
|
|
9b5f38039d | ||
|
|
789eb13f8a | ||
|
|
6690034b10 | ||
|
|
c0818c9dd0 | ||
|
|
cc0df6474a | ||
|
|
72cc6e7fdf | ||
|
|
d84b295e23 | ||
|
|
e0c1c415e5 | ||
|
|
1ea573ebfd | ||
|
|
3eb3722e2c | ||
|
|
8e04b5089a | ||
|
|
b7714cfc68 | ||
|
|
5f44b7f4b3 | ||
|
|
639ff976e2 | ||
|
|
68c924047d | ||
|
|
2397927681 | ||
|
|
ece9bd83e4 | ||
|
|
7693cbf3ae | ||
|
|
2e877ad476 | ||
|
|
a8f0db17ea | ||
|
|
b709180587 | ||
|
|
1d70cf6358 | ||
|
|
2f0931d3ca | ||
|
|
a1035f1372 | ||
|
|
d5b8d52e1d | ||
|
|
6568a7ae16 | ||
|
|
d752076339 | ||
|
|
702f90bfc9 | ||
|
|
fa3dfbbc4a | ||
|
|
84d6849cf3 | ||
|
|
ed1d641f22 | ||
|
|
692ab7f7c2 | ||
|
|
32253d862a | ||
|
|
4e78514948 | ||
|
|
bac147a460 | ||
|
|
2181c69162 | ||
|
|
963d4f327a | ||
|
|
97f8b8c87c | ||
|
|
8df468bfc6 | ||
|
|
8ca6a835ec | ||
|
|
251397694e | ||
|
|
94808da369 | ||
|
|
edfe7c6351 | ||
|
|
95d519ae64 | ||
|
|
1c473ed64c | ||
|
|
cb8aa0b017 | ||
|
|
d3c024e4df | ||
|
|
74a0b5796a | ||
|
|
4764e85f37 | ||
|
|
0ab59399c8 | ||
|
|
6bbceb2fa8 | ||
|
|
d6583cebf8 | ||
|
|
cc9deaf64b | ||
|
|
76fd9b9ae8 | ||
|
|
62b1ba04d0 | ||
|
|
e4873dd480 | ||
|
|
9b4047b1d4 | ||
|
|
ac19978d37 | ||
|
|
6eb04f9a1c | ||
|
|
a681a3c667 | ||
|
|
c7e259b3ce | ||
|
|
bcbcf3b968 | ||
|
|
e6ef0aa368 | ||
|
|
695eb6acc5 | ||
|
|
5475691ea4 | ||
|
|
5e430d057f | ||
|
|
b089188ee5 | ||
|
|
9e0f00c557 | ||
|
|
a0b1da61aa | ||
|
|
e9892a6f9d | ||
|
|
b297049e52 | ||
|
|
54c4549fe9 | ||
|
|
14e2a480f0 | ||
|
|
33f03ba0f7 | ||
|
|
18bd8fd881 | ||
|
|
a39567306b | ||
|
|
14b111f6f5 | ||
|
|
d3d55e8327 | ||
|
|
ecf1236b7c | ||
|
|
f28b9c46d3 | ||
|
|
6aabe43386 | ||
|
|
b9b6bf57de | ||
|
|
64b3361125 | ||
|
|
44684b7950 | ||
|
|
61c3042efe | ||
|
|
2fc2419fa1 | ||
|
|
5cec4d0720 | ||
|
|
b3860eb734 | ||
|
|
931b0dd067 | ||
|
|
fa7cd628e5 | ||
|
|
25aff046f3 | ||
|
|
9f84c3b60d | ||
|
|
8e808e8d92 | ||
|
|
690eefb088 | ||
|
|
536b4fffec | ||
|
|
b01902bbae | ||
|
|
dca358d435 | ||
|
|
1543fef701 | ||
|
|
ac19325c57 | ||
|
|
3ba3120637 | ||
|
|
b56f052a59 | ||
|
|
5388d20088 | ||
|
|
b2a5997e55 | ||
|
|
43799cc903 | ||
|
|
a6369c4009 | ||
|
|
6435019603 | ||
|
|
99e9f25796 | ||
|
|
179461aee3 | ||
|
|
3e5439cc94 | ||
|
|
c9e12bd9ff | ||
|
|
cd75e11841 | ||
|
|
5fddd79583 | ||
|
|
035c378758 | ||
|
|
9fe9beffbc | ||
|
|
e325de9db4 | ||
|
|
751d25a7f7 | ||
|
|
6a122dc684 | ||
|
|
ae029e0851 | ||
|
|
f2a9888f75 | ||
|
|
765e94e0a1 | ||
|
|
1f1374fd46 | ||
|
|
ade1a86a03 | ||
|
|
79e0e98321 | ||
|
|
5b30b47769 | ||
|
|
c884fc3bcc | ||
|
|
87c82993e7 | ||
|
|
c8f676252f | ||
|
|
58822f6229 | ||
|
|
77d8558fc4 | ||
|
|
0fa8070647 | ||
|
|
e1b29a3fd4 | ||
|
|
5895918791 | ||
|
|
91ae057cac | ||
|
|
ae8c9281d1 | ||
|
|
60c3efdfc3 | ||
|
|
f47fb86077 | ||
|
|
ed68b2e685 | ||
|
|
90fa467d7e | ||
|
|
78350843e4 | ||
|
|
713d81d64b | ||
|
|
cd3a942376 | ||
|
|
dead4367b0 | ||
|
|
a99c084805 | ||
|
|
60639aa677 | ||
|
|
f0d1ea9c4e | ||
|
|
c07035c444 | ||
|
|
3480c99b3e | ||
|
|
5eee65edac | ||
|
|
d9006c96f6 | ||
|
|
6a82142d69 | ||
|
|
1926c9029f | ||
|
|
c11764a12a | ||
|
|
725eca2701 | ||
|
|
07d7d5d498 | ||
|
|
8c539426eb | ||
|
|
d41de88c05 | ||
|
|
dd502bac9a | ||
|
|
13e803ab07 | ||
|
|
6471d93b10 | ||
|
|
f0862221c1 | ||
|
|
ef3e50bb2d | ||
|
|
7aaeef2676 | ||
|
|
c678ab06d5 | ||
|
|
ecd6618f6f | ||
|
|
35ade2dfbf | ||
|
|
71fb91daeb | ||
|
|
d7037d117f | ||
|
|
fed72cd3c9 | ||
|
|
99e58473b2 | ||
|
|
acbd08eb73 | ||
|
|
e343b7aa6d | ||
|
|
73a10f5b6d | ||
|
|
978132fdca | ||
|
|
a9812d0cb4 | ||
|
|
fa28792363 | ||
|
|
c56fa85a98 | ||
|
|
fbfc1b0296 | ||
|
|
e79027f728 | ||
|
|
6c033ea5dc | ||
|
|
6b7b686093 | ||
|
|
af269b1104 | ||
|
|
8a4fec438a | ||
|
|
1c37bda98f | ||
|
|
f9e2916ec7 | ||
|
|
35cdbb1ca8 | ||
|
|
a940fae193 | ||
|
|
a234a20876 | ||
|
|
edb3cbfd8a | ||
|
|
03a3bbd561 | ||
|
|
031537ec02 | ||
|
|
20ba473e85 | ||
|
|
7b6b25e6ab | ||
|
|
fbfa87d614 | ||
|
|
994c820bec | ||
|
|
fb725ce33f | ||
|
|
77ebac813a | ||
|
|
7ccff3b96e | ||
|
|
139e9633cf | ||
|
|
cf085041ed | ||
|
|
1dc86d752c | ||
|
|
372080cd5a | ||
|
|
3c50fd58e6 | ||
|
|
7bb11f4604 | ||
|
|
63bcaa147f | ||
|
|
86f4bde867 | ||
|
|
33accc3892 | ||
|
|
77cfb94c2f | ||
|
|
2cb203fab0 | ||
|
|
208aa023b0 | ||
|
|
a4716d9e4f | ||
|
|
aaf3094c8d | ||
|
|
978312b5cc | ||
|
|
3e176be41a | ||
|
|
56e65bf49f | ||
|
|
4e8515aaab | ||
|
|
f1c023ed3d | ||
|
|
17d56cb76a | ||
|
|
2d8fc776b2 | ||
|
|
1f45462529 | ||
|
|
6d9b1a64bf | ||
|
|
81733ffd81 | ||
|
|
17aafcd5bf | ||
|
|
3af5280ea6 | ||
|
|
559d340d1c | ||
|
|
9ae3b365a1 | ||
|
|
96085366f0 | ||
|
|
2a837254b0 | ||
|
|
b05e6a6b86 | ||
|
|
d6792ffddf | ||
|
|
594362d717 | ||
|
|
29a2445a40 | ||
|
|
f6b41efb16 | ||
|
|
dfd6cbeea6 | ||
|
|
f80c3778bd | ||
|
|
05c9affa2d | ||
|
|
536d6b88ac | ||
|
|
31c31b726b | ||
|
|
67dc806ca9 | ||
|
|
3d241724c4 | ||
|
|
1da831e4d0 | ||
|
|
99b89cd986 | ||
|
|
2d85c4a39d | ||
|
|
d499b4e4d9 | ||
|
|
a06277b7d3 | ||
|
|
89fa4e4b59 | ||
|
|
0d320e61d6 | ||
|
|
2df78c9e58 | ||
|
|
0c7f5bf398 | ||
|
|
39ff9f9bd5 | ||
|
|
3ce8056dab | ||
|
|
4ddbb5a398 | ||
|
|
738a49aa47 | ||
|
|
090553d14d | ||
|
|
12498b3acf | ||
|
|
8ee7519a5f | ||
|
|
6467c96e16 | ||
|
|
6797c6b483 | ||
|
|
aa7e0bbc34 | ||
|
|
465601b76c | ||
|
|
b7e39afe79 | ||
|
|
ed737afda0 | ||
|
|
7361cb222c | ||
|
|
b538b23d40 | ||
|
|
6f92e24440 | ||
|
|
83e6108346 | ||
|
|
b453f587be | ||
|
|
6cde09f38f | ||
|
|
ba45333986 | ||
|
|
1397c4b42a | ||
|
|
73098bf5a3 | ||
|
|
2c3d7a743f | ||
|
|
87be53279b | ||
|
|
f239818d28 | ||
|
|
482dcacd37 | ||
|
|
8695c3391f | ||
|
|
a1d7c488f8 | ||
|
|
d6c29aaf25 | ||
|
|
103089535a | ||
|
|
3c7f450137 | ||
|
|
6fc33e1ee1 | ||
|
|
b28ce8ef15 | ||
|
|
b7222213c9 | ||
|
|
6c79be9130 | ||
|
|
2da4b36b45 | ||
|
|
530320a6bd | ||
|
|
0a6f8d3db8 | ||
|
|
44d5cf7590 | ||
|
|
48c61e80c1 | ||
|
|
fc889bead4 | ||
|
|
0e53cfc240 | ||
|
|
dd1a857d49 | ||
|
|
15e308c3eb | ||
|
|
5d846aa65d | ||
|
|
9ac38222e1 | ||
|
|
8760dc4a41 | ||
|
|
2801199bea | ||
|
|
8c19c9f60a | ||
|
|
e4f92df78c | ||
|
|
c41b1e311d | ||
|
|
a77b0cc472 | ||
|
|
9e2f383166 | ||
|
|
a90cdcd2f3 | ||
|
|
f4761b459c | ||
|
|
7707379812 | ||
|
|
b273f8e608 | ||
|
|
c8dff74948 |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
## تاريخ النجوم
|
||||
|
||||
[](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>
|
||||
@@ -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">
|
||||
@@ -314,7 +315,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
|
||||
|
||||
@@ -409,6 +410,9 @@ nix build github:storytold/lightcraft # → ./result/bin/{light
|
||||
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
|
||||
```
|
||||
|
||||
On NixOS, run a `cargo` build from `nix develop`: outside it the app can't find `libvulkan.so.1`, finds no GPU and
|
||||
renders on the CPU, much more slowly (it says so once at start-up). `nix run` and `nix build` set the path themselves.
|
||||
|
||||
In a flake configuration (NixOS, home-manager, nix-darwin):
|
||||
|
||||
```nix
|
||||
|
||||
+2
-1
@@ -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">
|
||||
|
||||
+14
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
|
||||
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
|
||||
@@ -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
|
||||
@@ -46,6 +46,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) |
|
||||
@@ -109,11 +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
|
||||
@@ -161,6 +169,9 @@ 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 |
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
@@ -303,10 +314,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--connect" => {
|
||||
// Optional address argument.
|
||||
match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) {
|
||||
// Optional address argument. A bare port (all digits) is taken as a port on the
|
||||
// loopback default — matches the help's `--connect [ADDR]` hint alongside the
|
||||
// `--control 7980` example. A non-numeric, non-`--`-prefixed token (e.g. a
|
||||
// command id like `ui.inspect`) leaves the address at its default so the
|
||||
// command is still consumed next. (#1178)
|
||||
match args.get(i + 1).filter(|a| a.contains(':') || a.chars().all(|c| c.is_ascii_digit())) {
|
||||
Some(a) => {
|
||||
connect = Some(a.clone());
|
||||
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
|
||||
i += 1;
|
||||
}
|
||||
None => connect = Some(DEFAULT_ADDR.to_string()),
|
||||
@@ -525,9 +540,9 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
"--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) {
|
||||
"--connect" => match args.get(i + 1).filter(|a| !a.contains('=') && (a.contains(':') || a.chars().all(|c| c.is_ascii_digit()))) {
|
||||
Some(a) => {
|
||||
connect = Some(a.clone());
|
||||
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
|
||||
i += 1;
|
||||
}
|
||||
None => connect = Some(DEFAULT_ADDR.to_string()),
|
||||
|
||||
@@ -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();
|
||||
@@ -476,6 +570,28 @@ fn connect_failure_hint_names_the_attempted_port() {
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
}
|
||||
|
||||
/// Issue #1178: `--connect 7980 ui.inspect` (after `lightcraft --control 7980`) was leaving
|
||||
/// `7980` unconsumed so it parsed as a command id and produced "unknown command `7980`".
|
||||
/// A bare port is now taken as `127.0.0.1:<port>`; the same address used with `:port` still
|
||||
/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through.
|
||||
#[test]
|
||||
fn connect_bare_port_is_treated_as_loopback_port() {
|
||||
// Bare port: the failure hint names that port.
|
||||
let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}");
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
// `<host>:<port>` still works (issue #167 case).
|
||||
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "{err}");
|
||||
// `--connect=<bare-port>` already worked (the `a.starts_with("--connect=")` arm); spot-check
|
||||
// the hint matches its port too.
|
||||
let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "{err}");
|
||||
}
|
||||
|
||||
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
|
||||
/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
|
||||
#[test]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
reveal: None,
|
||||
log_file: None,
|
||||
open_with: None,
|
||||
open_all_with: None,
|
||||
check_editor: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -64,7 +64,7 @@ impl WireJob {
|
||||
/// `view`: the slot name, for jobs that keep a stage cache.
|
||||
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! The ICC profile the desktop assigns to the display (Linux, through colord).
|
||||
//!
|
||||
//! GNOME, KDE and most other desktops keep each monitor's profile in colord
|
||||
//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a
|
||||
//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile
|
||||
//! is chosen in Settings ▸ Display, previews are shown through that profile
|
||||
//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never
|
||||
//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB.
|
||||
//!
|
||||
//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a
|
||||
//! window is on), so the profile is used only when every display that has one has the same: a
|
||||
//! mixed set stays sRGB rather than guess.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often colord is asked again (a profile change in the desktop's settings shows within this).
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
/// A wait before trying again when colord isn't there (not installed, no system bus).
|
||||
const RETRY: Duration = Duration::from_secs(30);
|
||||
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
|
||||
const MAX_PROFILE: u64 = 16 << 20;
|
||||
|
||||
const SERVICE: &str = "org.freedesktop.ColorManager";
|
||||
const DEVICE: &str = "org.freedesktop.ColorManager.Device";
|
||||
const PROFILE: &str = "org.freedesktop.ColorManager.Profile";
|
||||
|
||||
/// Start the background reader and return the service the UI polls (`Services::screen_profile`).
|
||||
pub fn service() -> Box<dyn FnMut() -> Option<Vec<u8>>> {
|
||||
let shared: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
|
||||
let writer = Arc::clone(&shared);
|
||||
let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || {
|
||||
loop {
|
||||
let wait = match connect() {
|
||||
Some(conn) => {
|
||||
let icc = read(&conn);
|
||||
*writer.lock().unwrap_or_else(PoisonError::into_inner) = icc;
|
||||
POLL
|
||||
}
|
||||
None => RETRY,
|
||||
};
|
||||
std::thread::sleep(wait);
|
||||
// the app is gone: nobody reads the profile any more
|
||||
if Arc::strong_count(&writer) == 1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("screen profile: no colord reader: {e}");
|
||||
}
|
||||
Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone())
|
||||
}
|
||||
|
||||
fn connect() -> Option<zbus::blocking::Connection> {
|
||||
zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok()
|
||||
}
|
||||
|
||||
/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays).
|
||||
fn read(conn: &zbus::blocking::Connection) -> Option<Vec<u8>> {
|
||||
let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?;
|
||||
let devices: Vec<zbus::zvariant::OwnedObjectPath> = manager.call("GetDevicesByKind", &("display",)).ok()?;
|
||||
let mut files = Vec::new();
|
||||
for device in devices.iter().take(64) {
|
||||
let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue };
|
||||
let profiles: Vec<zbus::zvariant::OwnedObjectPath> = proxy.get_property("Profiles").unwrap_or_default();
|
||||
// colord lists a device's profiles default first
|
||||
let Some(first) = profiles.first() else { continue };
|
||||
let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue };
|
||||
if let Ok(file) = profile.get_property::<String>("Filename") {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
let file = pick(&files)?;
|
||||
load(std::path::Path::new(file))
|
||||
}
|
||||
|
||||
/// The profile to use for these displays' profile files: the one they share, `None` with none or
|
||||
/// with different ones (empty names are ignored).
|
||||
fn pick(files: &[String]) -> Option<&str> {
|
||||
let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty());
|
||||
let first = named.next()?;
|
||||
named.all(|f| f == first).then_some(first)
|
||||
}
|
||||
|
||||
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
|
||||
fn load(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
|
||||
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_shared_profile_is_used_and_a_mixed_set_is_not() {
|
||||
let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(pick(&s(&[])), None);
|
||||
assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc"));
|
||||
assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc"));
|
||||
assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known");
|
||||
assert_eq!(pick(&s(&[""])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_files_are_read_with_a_size_cap() {
|
||||
let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let small = dir.join("small.icc");
|
||||
std::fs::write(&small, b"acsp").unwrap();
|
||||
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
|
||||
let empty = dir.join("empty.icc");
|
||||
std::fs::write(&empty, b"").unwrap();
|
||||
assert_eq!(load(&empty), None);
|
||||
assert_eq!(load(&dir.join("missing.icc")), None);
|
||||
let big = dir.join("big.icc");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
|
||||
assert_eq!(load(&big), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -55,16 +55,65 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a request waits for its reply.
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// A request the app has not picked up while it drew no frame for this long is given up: the
|
||||
/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no
|
||||
/// frame callbacks, so the app's loop is paused: issue #718).
|
||||
const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The reply for a request given up because the app drew no frames.
|
||||
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
|
||||
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let state = Arc::clone(&req.state);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT))
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for a request's reply. While the app has not picked the request up and `frames` (its
|
||||
/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late)
|
||||
/// and the reply says why; a request the app is running gets the whole `timeout`.
|
||||
fn await_reply(
|
||||
rrx: &Receiver<Value>,
|
||||
state: &std::sync::atomic::AtomicU8,
|
||||
frames: impl Fn() -> u64,
|
||||
wake: impl Fn(),
|
||||
no_frames: Duration,
|
||||
timeout: Duration,
|
||||
) -> Value {
|
||||
let start = std::time::Instant::now();
|
||||
let slice = (no_frames / 10).max(Duration::from_millis(1));
|
||||
let (mut last, mut since) = (frames(), std::time::Instant::now());
|
||||
loop {
|
||||
match rrx.recv_timeout(slice) {
|
||||
Ok(v) => return v,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
let now = frames();
|
||||
if now != last {
|
||||
(last, since) = (now, std::time::Instant::now());
|
||||
}
|
||||
let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN;
|
||||
if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) {
|
||||
return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR});
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
// not run yet: make sure it never is; already running: it may still finish
|
||||
ControlRequest::abandon(state);
|
||||
return json!({"ok": false, "error": "timeout"});
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
@@ -160,6 +209,51 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
|
||||
/// up with a reason, quickly, and the app then skips it instead of running it late.
|
||||
#[test]
|
||||
fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() {
|
||||
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10));
|
||||
assert!(t0.elapsed() < Duration::from_secs(2));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}");
|
||||
assert!(!req.take(), "the app does not run it once it shows up");
|
||||
}
|
||||
|
||||
/// A request the app is running is not given up because frames stopped (a long command
|
||||
/// blocks the frame loop), and its reply arrives.
|
||||
#[test]
|
||||
fn a_request_being_run_waits_for_its_reply() {
|
||||
let (req, rrx) = ControlRequest::new("export.run", json!({}));
|
||||
assert!(req.take());
|
||||
let reply = req.reply.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let _ = reply.send(json!({"ok": true, "result": 1}));
|
||||
});
|
||||
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
/// Frames still coming: the request just waits, then is answered.
|
||||
#[test]
|
||||
fn a_drawing_app_is_waited_for() {
|
||||
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
|
||||
let n = std::sync::atomic::AtomicU64::new(0);
|
||||
let reply = req.reply.clone();
|
||||
let state = Arc::clone(&req.state);
|
||||
let worker = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
let _ = reply.send(json!({"ok": true, "result": 2}));
|
||||
});
|
||||
let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
|
||||
@@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
|
||||
c.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
|
||||
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
|
||||
/// still starting when it is started once per file. The system default opens them one by one
|
||||
/// (Explorer and `xdg-open` take one file).
|
||||
pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> {
|
||||
match all_command(launcher(app)?, paths) {
|
||||
Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()),
|
||||
None => paths.iter().try_for_each(|p| open_with(p, app)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one command that opens all of `paths` in `launcher` (none for the system default).
|
||||
fn all_command(launcher: Launcher, paths: &[String]) -> Option<Command> {
|
||||
match launcher {
|
||||
Launcher::Default => None,
|
||||
Launcher::MacApp(app) => {
|
||||
let mut c = Command::new("open");
|
||||
c.args(["-a", &app]).args(paths);
|
||||
Some(c)
|
||||
}
|
||||
Launcher::Program(p) => {
|
||||
let mut c = Command::new(p);
|
||||
c.args(paths);
|
||||
Some(c)
|
||||
}
|
||||
Launcher::Flatpak => {
|
||||
let mut c = Command::new("flatpak");
|
||||
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@");
|
||||
Some(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: open `path` in its default application through Explorer, given the path as its one
|
||||
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
|
||||
/// syntax (running what follows an `&` as a command).
|
||||
@@ -172,8 +205,10 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
|
||||
Some(rest) => rest.split('"').next()?,
|
||||
None => exec.split_whitespace().next()?,
|
||||
};
|
||||
// a bare name (`photocraft %F`) was already looked for on the PATH
|
||||
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
|
||||
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
|
||||
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
|
||||
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
|
||||
program.starts_with('/').then(|| PathBuf::from(program))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -191,6 +226,25 @@ mod tests {
|
||||
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
|
||||
}
|
||||
|
||||
/// Open Original with several photos: one start of the editor with every file, which a
|
||||
/// single-window editor (DxO PhotoLab) needs to get them all.
|
||||
#[test]
|
||||
fn several_files_go_to_one_start_of_the_editor() {
|
||||
let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()];
|
||||
let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
|
||||
let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap();
|
||||
assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe");
|
||||
assert_eq!(args(&c), paths);
|
||||
let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap();
|
||||
assert_eq!(
|
||||
(c.get_program().to_string_lossy().into_owned(), args(&c)),
|
||||
("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()])
|
||||
);
|
||||
let c = all_command(Launcher::Flatpak, &paths).unwrap();
|
||||
assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]);
|
||||
assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_lookup_prefers_the_recorded_install_folder() {
|
||||
let env = |k: &str| match k {
|
||||
@@ -216,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";
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR
|
||||
//! white, and with which surface format. Pure decisions, unit-tested here; the window always
|
||||
//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong.
|
||||
//!
|
||||
//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer,
|
||||
//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol),
|
||||
//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal
|
||||
//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the
|
||||
//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the
|
||||
//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same
|
||||
//! decision turns HDR on.
|
||||
|
||||
use eframe::wgpu::TextureFormat;
|
||||
|
||||
/// Windowing platform, as far as HDR output is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
MacOs,
|
||||
Windows,
|
||||
Wayland,
|
||||
X11,
|
||||
Web,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
/// The platform this build runs on (`wayland` from the session environment on Linux / BSD).
|
||||
pub fn current() -> Self {
|
||||
if cfg!(target_os = "macos") {
|
||||
Self::MacOs
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Self::Windows
|
||||
} else if cfg!(target_arch = "wasm32") {
|
||||
Self::Web
|
||||
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
Self::Wayland
|
||||
} else if std::env::var_os("DISPLAY").is_some() {
|
||||
Self::X11
|
||||
} else {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the platform has an HDR compositing path wgpu can reach at all.
|
||||
fn has_hdr_path(self) -> bool {
|
||||
matches!(self, Self::MacOs | Self::Windows | Self::Wayland)
|
||||
}
|
||||
}
|
||||
|
||||
/// How HDR values reach the display.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrEncoding {
|
||||
/// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB,
|
||||
/// Wayland extended linear).
|
||||
ExtendedLinear,
|
||||
/// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ).
|
||||
Pq10,
|
||||
}
|
||||
|
||||
/// The display output decision.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayOutput {
|
||||
Sdr { reason: &'static str },
|
||||
Hdr { format: TextureFormat, encoding: HdrEncoding },
|
||||
}
|
||||
|
||||
impl DisplayOutput {
|
||||
#[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR
|
||||
pub fn is_hdr(self) -> bool {
|
||||
matches!(self, Self::Hdr { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoding a surface format carries on `platform`, if it is an HDR one.
|
||||
fn encoding_of(format: TextureFormat, platform: Platform) -> Option<HdrEncoding> {
|
||||
match format {
|
||||
TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear),
|
||||
// macOS takes HDR through EDR floats only
|
||||
TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The best HDR surface format among those a surface offers (`available`), for `platform`:
|
||||
/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only.
|
||||
/// (For configuring the surface once eframe lets the app pick its format.)
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option<TextureFormat> {
|
||||
if !platform.has_hdr_path() {
|
||||
return None;
|
||||
}
|
||||
[TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some())
|
||||
}
|
||||
|
||||
/// The decision for the window: `target` is the format the renderer configured (`None`: the glow
|
||||
/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the
|
||||
/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS),
|
||||
/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`).
|
||||
pub fn choose(target: Option<TextureFormat>, platform: Platform, display_headroom: Option<f32>, disabled: bool) -> DisplayOutput {
|
||||
if disabled {
|
||||
return DisplayOutput::Sdr { reason: "HDR display is switched off" };
|
||||
}
|
||||
if !platform.has_hdr_path() {
|
||||
return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" };
|
||||
}
|
||||
let Some(format) = target else {
|
||||
return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" };
|
||||
};
|
||||
let Some(encoding) = encoding_of(format, platform) else {
|
||||
return DisplayOutput::Sdr { reason: "the window surface is 8-bit" };
|
||||
};
|
||||
match display_headroom {
|
||||
Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding },
|
||||
Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" },
|
||||
None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" },
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision for this start, logged once (the window renderer's `target` format).
|
||||
pub fn report(target: Option<TextureFormat>) -> DisplayOutput {
|
||||
let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
|
||||
// no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready
|
||||
let out = choose(target, Platform::current(), None, disabled);
|
||||
match out {
|
||||
DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"),
|
||||
DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use TextureFormat as F;
|
||||
|
||||
#[test]
|
||||
fn eight_bit_surfaces_are_sdr() {
|
||||
for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] {
|
||||
for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] {
|
||||
assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_needs_platform_format_and_headroom() {
|
||||
let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear };
|
||||
assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr);
|
||||
assert_eq!(
|
||||
choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false),
|
||||
DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 }
|
||||
);
|
||||
// any missing condition falls back to SDR
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr());
|
||||
assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr());
|
||||
// macOS has no PQ swap chain path
|
||||
assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_format_picks_float_then_pq() {
|
||||
let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float];
|
||||
assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float));
|
||||
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm));
|
||||
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None);
|
||||
assert_eq!(preferred_hdr_format(&all, Platform::X11), None);
|
||||
assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,12 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod appearance;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod colord;
|
||||
mod control_server;
|
||||
mod dialog_filter;
|
||||
mod editors;
|
||||
mod hdr_display;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
@@ -73,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>) {
|
||||
@@ -87,6 +91,16 @@ impl eframe::App for App {
|
||||
if !self.0.shutdown(EXIT_WAIT) {
|
||||
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
|
||||
}
|
||||
// macOS (#1331): end the process here, before eframe tears the window down, as Quit from
|
||||
// the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's
|
||||
// content view and restores its class on teardown; AppKit's Touch Bar support observes
|
||||
// that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation
|
||||
// invalidate]` throws "not registered" and closing the window ends in a crash report.
|
||||
// Nothing after this is needed: settings and library are saved, background work has
|
||||
// stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a
|
||||
// renderer that fails to start still relaunches on the next one (`window_gpu`).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +464,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
open_with: Some(Box::new(editors::open_with)),
|
||||
open_all_with: Some(Box::new(editors::open_all_with)),
|
||||
check_editor: Some(Box::new(editors::check)),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if !url.starts_with("https://") {
|
||||
@@ -525,7 +540,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
|
||||
#[cfg(target_os = "macos")]
|
||||
screen_profile: Some(Box::new(screen_profile::icc)),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// Linux: the profile the desktop assigns in colord, read off the UI thread
|
||||
#[cfg(target_os = "linux")]
|
||||
screen_profile: Some(colord::service()),
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
screen_profile: None,
|
||||
pick_display_profile: Some(Box::new(|| {
|
||||
let mut d = file_dialog()
|
||||
@@ -833,6 +851,8 @@ fn main() -> eframe::Result {
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session = session.with_default_sam3_model();
|
||||
// HDR display: SDR unless the surface, platform and display all allow HDR
|
||||
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
|
||||
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
@@ -842,6 +862,7 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
@@ -1207,6 +1228,35 @@ mod tests {
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
|
||||
/// The check runs in a child (this test binary again), since on macOS it ends the process.
|
||||
#[test]
|
||||
fn on_exit_ends_the_process_on_macos() {
|
||||
if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
|
||||
let mut app = App(
|
||||
app,
|
||||
PrefsWriter::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
);
|
||||
println!("calling on_exit");
|
||||
eframe::App::on_exit(&mut app, None);
|
||||
println!("on_exit returned");
|
||||
return;
|
||||
}
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
|
||||
.env("LIGHTCRAFT_TEST_ON_EXIT", "1")
|
||||
.env("LIGHTCRAFT_NO_PREFS", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(out.status.success(), "{stdout}");
|
||||
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
|
||||
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
|
||||
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
||||
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
|
||||
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -74,7 +75,7 @@ pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 7;
|
||||
pub const VERSION: u32 = 8;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -454,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))
|
||||
}
|
||||
|
||||
|
||||
@@ -236,63 +236,84 @@ pub struct KeywordNode {
|
||||
impl Catalog {
|
||||
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
|
||||
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
|
||||
use std::collections::HashMap;
|
||||
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
|
||||
// path as first written, and the photos counted (once per photo, even when two of its
|
||||
// keywords share a parent)
|
||||
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
|
||||
struct Level {
|
||||
name: String,
|
||||
sort: String,
|
||||
path: String,
|
||||
count: usize,
|
||||
parent: Option<String>,
|
||||
seen: usize,
|
||||
}
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut this_photo: HashSet<String> = HashSet::new();
|
||||
let mut levels: Vec<Level> = Vec::new();
|
||||
let mut by_lower: HashMap<String, usize> = HashMap::new();
|
||||
// each level's children (by index), and the top level
|
||||
let mut kids: Vec<Vec<usize>> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
// the keyword list first (no photos counted): a listed keyword is there without photos
|
||||
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
|
||||
// each keyword as written → its levels (its own and those above it), worked out once: a
|
||||
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
|
||||
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
|
||||
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
|
||||
this_photo.clear();
|
||||
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
|
||||
for k in keywords {
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
let mut parent: Option<String> = None;
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
let chain = chains.entry(k.as_str()).or_insert_with(|| {
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
}
|
||||
path.push_str(part);
|
||||
let sort = part.to_lowercase();
|
||||
lower.push_str(&sort);
|
||||
let i = match by_lower.get(&lower) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = levels.len();
|
||||
match chain.last().and_then(|&p| kids.get_mut(p)) {
|
||||
Some(k) => k.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
|
||||
kids.push(Vec::new());
|
||||
by_lower.insert(lower.clone(), i);
|
||||
i
|
||||
}
|
||||
};
|
||||
chain.push(i);
|
||||
}
|
||||
path.push_str(part);
|
||||
lower.push_str(&part.to_lowercase());
|
||||
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
|
||||
name: part.to_string(),
|
||||
path: path.clone(),
|
||||
count: 0,
|
||||
parent: parent.clone(),
|
||||
});
|
||||
if counts && this_photo.insert(lower.clone()) {
|
||||
l.count += 1;
|
||||
chain
|
||||
});
|
||||
if counts {
|
||||
for &i in chain.iter() {
|
||||
let Some(l) = levels.get_mut(i) else { continue };
|
||||
// photo 0 is the keyword list, which counts nothing
|
||||
if l.seen != photo {
|
||||
l.seen = photo;
|
||||
l.count += 1;
|
||||
}
|
||||
}
|
||||
parent = Some(lower.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
|
||||
for (lower, l) in &levels {
|
||||
kids.entry(l.parent.clone()).or_default().push(lower.clone());
|
||||
}
|
||||
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
|
||||
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
|
||||
list.sort_by_key(|l| levels[l].name.to_lowercase());
|
||||
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
|
||||
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
|
||||
list.into_iter()
|
||||
.map(|l| {
|
||||
let lv = &levels[&l];
|
||||
let children = build(Some(l.clone()), levels, kids);
|
||||
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
|
||||
.filter_map(|i| {
|
||||
let lv = levels.get(i)?;
|
||||
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
|
||||
let children = build(below, levels, kids);
|
||||
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
build(None, &levels, &mut kids)
|
||||
build(roots, &levels, &mut kids)
|
||||
}
|
||||
|
||||
/// `SetMeta` ops for every photo whose keywords `f` changes.
|
||||
@@ -804,6 +825,15 @@ mod tests {
|
||||
assert_eq!(c.query(&f, &Default::default()).len(), 3);
|
||||
}
|
||||
|
||||
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
|
||||
/// photo with it counts again.
|
||||
#[test]
|
||||
fn tree_counts_a_repeated_keyword_once_per_photo() {
|
||||
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
|
||||
let t = c.keyword_tree();
|
||||
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_delete_merge_are_single_undoable_batches() {
|
||||
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
|
||||
|
||||
@@ -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};
|
||||
@@ -125,6 +127,12 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
step: HistoryStep,
|
||||
},
|
||||
/// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps.
|
||||
SetHistoryCursor {
|
||||
id: PhotoId,
|
||||
#[serde(default)]
|
||||
cursor: Option<usize>,
|
||||
},
|
||||
AddAlbum {
|
||||
album: Album,
|
||||
},
|
||||
@@ -275,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 {
|
||||
@@ -479,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)
|
||||
}
|
||||
|
||||
@@ -545,12 +562,25 @@ impl Catalog {
|
||||
Op::PushHistory { id, step } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
let old = p.history.clone();
|
||||
let old_cursor = p.history_cursor.take();
|
||||
// a new edit made from an earlier step drops the steps after it
|
||||
if let Some(c) = old_cursor {
|
||||
p.history.truncate(c.saturating_add(1));
|
||||
}
|
||||
p.history.push(step);
|
||||
if p.history.len() > HISTORY_LIMIT {
|
||||
let n = p.history.len() - HISTORY_LIMIT;
|
||||
p.history.drain(..n);
|
||||
}
|
||||
Op::SetHistory { id, history: old }
|
||||
if old_cursor.is_none() {
|
||||
Op::SetHistory { id, history: old }
|
||||
} else {
|
||||
Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] }
|
||||
}
|
||||
}
|
||||
Op::SetHistoryCursor { id, cursor } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) }
|
||||
}
|
||||
Op::AddAlbum { album } => {
|
||||
if self.albums.contains_key(&album.id) {
|
||||
|
||||
@@ -279,6 +279,7 @@ impl Op {
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetHistoryCursor { id, .. }
|
||||
| Op::SetCaptured { id, .. }
|
||||
| Op::SetAnalysis { id, .. }
|
||||
| Op::SetFile { id, .. }
|
||||
|
||||
@@ -135,8 +135,9 @@ impl LibraryLock {
|
||||
}
|
||||
|
||||
/// Another descriptor on the locked file: what a process spawned by another thread holds
|
||||
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
|
||||
#[cfg(test)]
|
||||
/// between fork and exec (a `dup` refers to the same open file description). Tests only (the
|
||||
/// test that uses it is Unix-only: `flock` semantics).
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) fn descriptor_copy(&self) -> Option<File> {
|
||||
self.file.as_ref().and_then(|f| f.try_clone().ok())
|
||||
}
|
||||
|
||||
@@ -247,6 +247,11 @@ pub struct Photo {
|
||||
pub versions: Vec<Version>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<HistoryStep>,
|
||||
/// The History step the photo's settings are at, when it isn't the latest one (Go to History
|
||||
/// Step moved back): later steps stay listed and can be returned to until a new edit drops
|
||||
/// them. `None` (and catalogs written before the cursor existed) = the latest step.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub history_cursor: Option<usize>,
|
||||
/// In "Recently Deleted".
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
@@ -313,20 +318,27 @@ 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
|
||||
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
|
||||
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
|
||||
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
|
||||
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
|
||||
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
|
||||
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor
|
||||
/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin:
|
||||
/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays
|
||||
/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through
|
||||
/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree.
|
||||
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
|
||||
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
|
||||
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
|
||||
pub fn history_index(&self) -> Option<usize> {
|
||||
let last = self.history.len().checked_sub(1)?;
|
||||
Some(self.history_cursor.map_or(last, |c| c.min(last)))
|
||||
}
|
||||
|
||||
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
|
||||
Photo {
|
||||
id,
|
||||
@@ -347,6 +359,7 @@ impl Photo {
|
||||
edited: None,
|
||||
versions: Vec::new(),
|
||||
history: Vec::new(),
|
||||
history_cursor: None,
|
||||
deleted: false,
|
||||
local: false,
|
||||
duration: None,
|
||||
@@ -564,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");
|
||||
@@ -599,6 +613,8 @@ mod edited_tests {
|
||||
("FUJIFILM X-T4", "RAF", false),
|
||||
("ILCE-7M3", "ARW", false),
|
||||
("SONY ILCE-7M2", "ARW", true),
|
||||
("SONY ILCA-99M2", "ARW", false),
|
||||
("SONY SLT-A33", "ARW", false),
|
||||
("SONY ILCE-1", "ARW", true),
|
||||
("", "ARW", true),
|
||||
("SONY ILCE-7M3", "DNG", false),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Filtering, search and sorting.
|
||||
|
||||
use std::cmp::Ordering::Equal;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
|
||||
@@ -359,8 +361,13 @@ impl Filter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`).
|
||||
pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase))
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
|
||||
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
|
||||
let root = filter.library_root();
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
@@ -369,11 +376,15 @@ impl Catalog {
|
||||
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
|
||||
SortKey::ImportDate => a.imported.cmp(&b.imported),
|
||||
SortKey::EditDate => a.edited.cmp(&b.edited),
|
||||
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
|
||||
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
|
||||
SortKey::Rating => a.rating.cmp(&b.rating),
|
||||
SortKey::FileSize => a.file_size.cmp(&b.file_size),
|
||||
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
|
||||
}
|
||||
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
|
||||
// order cameras number frames, so the order doesn't depend on how ids were allocated;
|
||||
// the id only keeps it stable.
|
||||
.then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
|
||||
.then(a.id.cmp(&b.id));
|
||||
if sort.ascending { o } else { o.reverse() }
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::query::cmp_name;
|
||||
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
|
||||
|
||||
impl Catalog {
|
||||
@@ -203,17 +204,19 @@ impl Catalog {
|
||||
/// Only runs of two or more are returned.
|
||||
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
|
||||
let index = self.stack_index();
|
||||
let mut timed: Vec<(i64, PhotoId)> = ids
|
||||
let mut timed: Vec<(i64, &str, PhotoId)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| self.photos.get(id))
|
||||
.filter(|p| !p.deleted && !index.contains_key(&p.id))
|
||||
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id)))
|
||||
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id)))
|
||||
.collect();
|
||||
timed.sort();
|
||||
timed.dedup_by_key(|(_, id)| *id);
|
||||
// a burst within one second stays in file name order (the first file is the stack's cover)
|
||||
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
|
||||
// equal ids are the same photo, so same time and name: adjacent after the sort
|
||||
timed.dedup_by_key(|(_, _, id)| *id);
|
||||
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
|
||||
let mut last: Option<i64> = None;
|
||||
for (t, id) in timed {
|
||||
for (t, _, id) in timed {
|
||||
match (last, groups.last_mut()) {
|
||||
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
|
||||
_ => groups.push(vec![id]),
|
||||
@@ -264,6 +267,22 @@ mod tests {
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_stack_keeps_a_burst_in_file_order() {
|
||||
let mut c = Catalog::new();
|
||||
let mut by_name = Vec::new();
|
||||
for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() {
|
||||
let id = PhotoId(1000 - n as u64);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
|
||||
p.captured = Some("2026-04-01T10:00:00".into());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
by_name.push(id);
|
||||
}
|
||||
let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0);
|
||||
assert_eq!(groups, vec![by_name.clone()]);
|
||||
assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_seconds_parses() {
|
||||
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -694,3 +694,46 @@ fn undated_photos_group_under_unknown_date_and_sort_together() {
|
||||
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
|
||||
}
|
||||
|
||||
/// Photos with one import time and capture time, as one import batch or a burst gives them, added
|
||||
/// with ids that run opposite to their names.
|
||||
fn batch_with_reversed_ids(c: &mut Catalog) -> Vec<PhotoId> {
|
||||
let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"];
|
||||
let mut ids = Vec::new();
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
let id = PhotoId(1000 - n as u64);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00");
|
||||
p.captured = Some("2026-10-01T12:00:00".into());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
ids // in name order
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_import_batch_sorts_by_file_name_not_id() {
|
||||
let mut c = Catalog::new();
|
||||
let by_name = batch_with_reversed_ids(&mut c);
|
||||
let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() };
|
||||
assert_eq!(c.query(&Filter::default(), &sort), by_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_in_one_second_sorts_by_file_name_not_id() {
|
||||
let mut c = Catalog::new();
|
||||
let by_name = batch_with_reversed_ids(&mut c);
|
||||
let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() };
|
||||
assert_eq!(c.query(&Filter::default(), &sort), by_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmp_name_ignores_case_without_allocating() {
|
||||
use crate::query::cmp_name;
|
||||
use std::cmp::Ordering::*;
|
||||
assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal);
|
||||
assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal);
|
||||
assert_eq!(cmp_name("a", "B"), Less);
|
||||
assert_eq!(cmp_name("B", "a"), Greater);
|
||||
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
|
||||
assert_eq!(cmp_name("a", "ab"), Less);
|
||||
}
|
||||
@@ -211,8 +211,9 @@ fn op_variants_are_versioned() {
|
||||
Op::SetKeyword { .. } => 5,
|
||||
Op::SetFolderRecord { .. } => 6,
|
||||
Op::SetAsShotWb { .. } => 7,
|
||||
Op::SetHistoryCursor { .. } => 8,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
|
||||
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -140,8 +140,7 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
|
||||
std::thread::spawn(move || {
|
||||
let mut spawned = 0usize;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Ok(mut c) =
|
||||
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
||||
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
||||
{
|
||||
let _ = c.wait();
|
||||
spawned += 1;
|
||||
|
||||
@@ -11,7 +11,7 @@ let fmt: Option<Format> = sniff(&bytes); // JPEG/PNG/TIFF/WebP/A
|
||||
let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
// d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace)
|
||||
// d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs)
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford), alpha flattened onto white
|
||||
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
|
||||
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
|
||||
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
|
||||
@@ -53,7 +53,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) |
|
||||
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
|
||||
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
|
||||
|
||||
|
||||
@@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 {
|
||||
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
|
||||
}
|
||||
|
||||
/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum HdrTransfer {
|
||||
#[default]
|
||||
Pq,
|
||||
Hlg,
|
||||
}
|
||||
|
||||
/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408).
|
||||
pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0;
|
||||
|
||||
/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100:
|
||||
/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma
|
||||
/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75,
|
||||
/// as BT.2408 asks. Non-finite or negative inputs count as 0.
|
||||
pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] {
|
||||
const K: [f32; 3] = [0.2627, 0.6780, 0.0593];
|
||||
const GAMMA: f32 = 1.2;
|
||||
let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS));
|
||||
let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0);
|
||||
let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 };
|
||||
fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]).
|
||||
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq)
|
||||
}
|
||||
|
||||
/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
|
||||
/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
|
||||
/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
|
||||
/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level
|
||||
/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display).
|
||||
/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
|
||||
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
|
||||
@@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
|
||||
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
|
||||
sum_nits += y_nits as f64;
|
||||
let [r, g, b] = nits.map(pq_encode);
|
||||
let [r, g, b] = match transfer {
|
||||
HdrTransfer::Pq => nits.map(pq_encode),
|
||||
HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]),
|
||||
};
|
||||
let y = K[0] * r + K[1] * g + K[2] * b;
|
||||
let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
|
||||
let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
|
||||
@@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
ec.pixel_range = PixelRange::Full;
|
||||
ec.color_description = Some(ColorDescription {
|
||||
color_primaries: ColorPrimaries::BT2020,
|
||||
transfer_characteristics: TransferCharacteristics::SMPTE2084,
|
||||
transfer_characteristics: match transfer {
|
||||
HdrTransfer::Pq => TransferCharacteristics::SMPTE2084,
|
||||
HdrTransfer::Hlg => TransferCharacteristics::HLG,
|
||||
},
|
||||
matrix_coefficients: MatrixCoefficients::BT2020NCL,
|
||||
});
|
||||
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
|
||||
ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
|
||||
ec.content_light =
|
||||
(transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
|
||||
ec.still_picture = true;
|
||||
ec.quantizer = quantizer;
|
||||
ec.min_quantizer = quantizer.min(255) as u8;
|
||||
@@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
use avif_serialize::constants as c;
|
||||
let mut a = avif_serialize::Aviffy::new();
|
||||
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
|
||||
.set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
|
||||
.set_transfer_characteristics(match transfer {
|
||||
HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084,
|
||||
HdrTransfer::Hlg => c::TransferCharacteristics::Hlg,
|
||||
})
|
||||
.set_color_primaries(c::ColorPrimaries::Bt2020)
|
||||
.set_full_color_range(true)
|
||||
.set_content_light_level(cll.0, cll.1);
|
||||
.set_full_color_range(true);
|
||||
if transfer == HdrTransfer::Pq {
|
||||
a.set_content_light_level(cll.0, cll.1);
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
a.set_exif(exif.to_vec());
|
||||
}
|
||||
@@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
}
|
||||
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (quality, speed, meta);
|
||||
let _ = (quality, speed, meta, transfer);
|
||||
Err(Error::Encode("AVIF encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
@@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<
|
||||
.map_err(|e| Error::Encode(e.to_string()))?;
|
||||
Ok(r.avif_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hlg_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hlg_reference_white_and_peak() {
|
||||
// BT.2408: SDR (HDR reference) white sits at 75 % HLG signal
|
||||
for v in hlg_signal([1.0; 3]) {
|
||||
assert!((v - 0.75).abs() < 0.005, "{v}");
|
||||
}
|
||||
// the 1000 cd/m² peak (≈ 4.9× SDR white) reaches full signal; above it clips
|
||||
let peak = HLG_REFERENCE_PEAK_NITS / HDR_REFERENCE_WHITE_NITS;
|
||||
assert!(hlg_signal([peak; 3]).iter().all(|v| (v - 1.0).abs() < 1e-3));
|
||||
assert_eq!(hlg_signal([100.0; 3]), [1.0; 3]);
|
||||
// black, negatives and non-finite values are 0, never NaN
|
||||
assert_eq!(hlg_signal([0.0, -1.0, f32::NAN]), [0.0; 3]);
|
||||
assert!(hlg_signal([f32::INFINITY, 0.0, 0.0]).iter().all(|v| v.is_finite()));
|
||||
// monotonic in brightness
|
||||
let mut prev = 0.0;
|
||||
for i in 1..50 {
|
||||
let v = hlg_signal([i as f32 * 0.1; 3])[1];
|
||||
assert!(v >= prev);
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_avif_rejects_short_buffers() {
|
||||
let m = EncodeMeta::default();
|
||||
assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err());
|
||||
assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err());
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,8 @@ mod tiff_codec;
|
||||
mod webp;
|
||||
|
||||
pub use encode::{
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
|
||||
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression,
|
||||
encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal,
|
||||
};
|
||||
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
|
||||
pub use sniff::{Format, sniff};
|
||||
@@ -244,12 +244,24 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
||||
}
|
||||
|
||||
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
|
||||
///
|
||||
/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest
|
||||
/// of the pipeline (issue #1212).
|
||||
pub fn to_working(d: &Decoded) -> Rgb32f {
|
||||
if d.space.is_working() {
|
||||
return d.image.clone();
|
||||
let mut img = if d.space.is_working() {
|
||||
d.image.clone()
|
||||
} else {
|
||||
let m = d.space.to_working().to_f32();
|
||||
d.image.map(|p| mat_apply(&m, p))
|
||||
};
|
||||
// white is (1, 1, 1) in the working space too: the conversion is white-preserving
|
||||
if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) {
|
||||
for (p, &av) in img.data.iter_mut().zip(&a.data) {
|
||||
let av = av.clamp(0.0, 1.0);
|
||||
*p = p.map(|c| c * av + (1.0 - av));
|
||||
}
|
||||
}
|
||||
let m = d.space.to_working().to_f32();
|
||||
d.image.map(|p| mat_apply(&m, p))
|
||||
img
|
||||
}
|
||||
|
||||
/// Where a thumbnail came from.
|
||||
|
||||
@@ -140,6 +140,24 @@ fn png_metadata_and_gray_alpha() {
|
||||
assert!((a - 128.0 / 255.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212:
|
||||
/// transparent areas, stored black, rendered black).
|
||||
#[test]
|
||||
fn png_transparency_develops_on_white() {
|
||||
// opaque red, transparent black (as most encoders store it), half-transparent red
|
||||
let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128];
|
||||
let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap();
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert!(d.has_alpha);
|
||||
let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap();
|
||||
let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0);
|
||||
let w = d.to_working();
|
||||
let a = 128.0 / 255.0;
|
||||
for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] {
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p3_icc_to_working() {
|
||||
let icc = write_named(NamedSpace::DisplayP3);
|
||||
|
||||
@@ -99,8 +99,45 @@ impl ControlSpec {
|
||||
let s = format!("{:.*}", self.decimals as usize, v);
|
||||
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
|
||||
}
|
||||
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
|
||||
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
|
||||
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
|
||||
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
|
||||
/// linear.
|
||||
pub fn position(&self, v: f64) -> f64 {
|
||||
let v = self.clamp(v);
|
||||
let p = match self.kelvin_scale() {
|
||||
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
|
||||
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
|
||||
None => (v - self.min) / (self.max - self.min).max(1e-9),
|
||||
};
|
||||
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
|
||||
pub fn value_at(&self, p: f64) -> f64 {
|
||||
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let v = match self.kelvin_scale() {
|
||||
Some(_) if p >= 1.0 => self.max,
|
||||
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
|
||||
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
|
||||
None => self.min + p * (self.max - self.min),
|
||||
};
|
||||
self.clamp(v)
|
||||
}
|
||||
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
|
||||
/// `None` for every other control, or a range the centre isn't inside.
|
||||
fn kelvin_scale(&self) -> Option<(f64, f64)> {
|
||||
let centre = KELVIN_CENTRE;
|
||||
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The Kelvin temperature at the middle of its track, as in Lightroom.
|
||||
const KELVIN_CENTRE: f64 = 7000.0;
|
||||
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
|
||||
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
|
||||
const KELVIN_KNEE: f64 = 1000.0;
|
||||
|
||||
macro_rules! controls {
|
||||
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
|
||||
/// All numeric develop controls, in panel order.
|
||||
@@ -508,4 +545,46 @@ mod tests {
|
||||
assert_eq!(e.format(-1.0), "-1.00");
|
||||
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
|
||||
}
|
||||
|
||||
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
|
||||
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
|
||||
#[test]
|
||||
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
|
||||
let temp = find("wb.temp").unwrap();
|
||||
for (k, lightroom, model) in [
|
||||
(2000.0, 0.0, 0.0),
|
||||
(3650.0, 0.167, 0.165),
|
||||
(5400.0, 0.333, 0.34),
|
||||
(7000.0, 0.5, 0.5),
|
||||
(15000.0, 0.79, 0.79),
|
||||
(20000.0, 0.853, 0.849),
|
||||
(30000.0, 0.90, 0.92),
|
||||
(50000.0, 1.0, 1.0),
|
||||
] {
|
||||
let p = temp.position(k);
|
||||
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
|
||||
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
|
||||
}
|
||||
// every 50 K step survives value → position → value, and the track never runs backwards
|
||||
let mut last = -1.0;
|
||||
for k in (2000..=50000).step_by(50).map(f64::from) {
|
||||
let p = temp.position(k);
|
||||
assert!(p > last, "{k} K");
|
||||
last = p;
|
||||
let back = (temp.value_at(p) / temp.step).round() * temp.step;
|
||||
assert_eq!(back, k, "{k} K → {p} → {back}");
|
||||
}
|
||||
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
|
||||
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
|
||||
let exposure = find("light.exposure").unwrap();
|
||||
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
|
||||
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
|
||||
}
|
||||
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
|
||||
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
|
||||
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
|
||||
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
|
||||
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
|
||||
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,7 +10,11 @@ use lightcraft_raster::{
|
||||
Rgb32f,
|
||||
resample::{Filter, fit, resize},
|
||||
};
|
||||
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
|
||||
use lightcraft_raw::{
|
||||
ColorData, RawFormat, RawImage,
|
||||
color::CameraTransform,
|
||||
profile::{HsvTable, ProfileLook, ToneCurve},
|
||||
};
|
||||
|
||||
/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone
|
||||
/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because
|
||||
@@ -25,7 +29,8 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa
|
||||
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
|
||||
/// states, for every make (#778).
|
||||
/// 4: a tone curve from black-and-white camera JPEGs (#577).
|
||||
pub const LOOK_VERSION: u32 = 4;
|
||||
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
|
||||
pub const LOOK_VERSION: u32 = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
@@ -58,6 +63,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
| RawFormat::Pef
|
||||
| RawFormat::Srw
|
||||
| RawFormat::Orf
|
||||
| RawFormat::Mrw
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,6 +113,69 @@ fn starting_colour_with(
|
||||
(xy, t, look)
|
||||
}
|
||||
|
||||
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
|
||||
/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered
|
||||
/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them).
|
||||
pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<ColorData> {
|
||||
if lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
return None;
|
||||
}
|
||||
let (_, t, look) = starting_colour(raw, bytes);
|
||||
dng_colour(&raw.color, &t, look.as_ref())
|
||||
}
|
||||
|
||||
/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward
|
||||
/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation
|
||||
/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table
|
||||
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
|
||||
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
|
||||
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
|
||||
/// `None` when the white balance or the matrix can't give tags.
|
||||
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
|
||||
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
|
||||
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
|
||||
// into the baseline exposure
|
||||
let white = m.apply([1.0; 3]);
|
||||
let k = (white[0] + white[1] + white[2]) / 3.0;
|
||||
if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let m = Mat3(m.0.map(|r| r.map(|v| v / k)));
|
||||
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
|
||||
let wb = t.wb.map(f64::from);
|
||||
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
|
||||
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
|
||||
Some(ColorData {
|
||||
illuminant: [21, 0],
|
||||
color_matrix: [Some(camera_to_xyz.inverse()?), None],
|
||||
forward_matrix: [Some(forward), None],
|
||||
camera_calibration: [None, None],
|
||||
analog_balance: None,
|
||||
as_shot_neutral: Some(neutral),
|
||||
as_shot_white_xy: None,
|
||||
baseline_exposure: t.baseline_exposure + k.log2(),
|
||||
baseline_sharpness: tags.baseline_sharpness,
|
||||
kelvin_presets: None,
|
||||
profile: ProfileLook {
|
||||
hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None],
|
||||
tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
|
||||
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
|
||||
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
|
||||
const N: usize = 256;
|
||||
let samples = (0..=N).map(|i| {
|
||||
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32);
|
||||
[x, tone.apply(x).min(1.0)]
|
||||
});
|
||||
let flat: Vec<f64> = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect();
|
||||
ToneCurve::from_tag(&flat)
|
||||
}
|
||||
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
@@ -265,12 +334,19 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and
|
||||
/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px);
|
||||
/// 128 MP also admits 100 MP medium-format previews.
|
||||
const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
|
||||
/// and per proxy pixel whether any sensor sample it covers is clipped.
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
bytes,
|
||||
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
|
||||
)?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
@@ -569,7 +645,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
|
||||
if (0.015..=1.0).contains(&y) {
|
||||
bright.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
|
||||
if !colour_pair(*input, *output) {
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
@@ -580,6 +656,77 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
|
||||
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
|
||||
}
|
||||
|
||||
/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting.
|
||||
fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool {
|
||||
input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|
||||
&& output.iter().all(|v| *v > 0.004 && *v < 0.98)
|
||||
&& (0.015..0.85).contains(&luminance_2020(output))
|
||||
}
|
||||
|
||||
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
|
||||
/// The original pairs and their split still own the colour fit and its acceptance gates.
|
||||
/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and
|
||||
/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise.
|
||||
fn fit_tone_with_shadows(
|
||||
fit: ToneFit,
|
||||
pairs: &[([f64; 3], [f64; 3])],
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
edge_limit: Option<f32>,
|
||||
colour: impl Fn([f64; 3]) -> [f64; 3],
|
||||
) -> Option<CameraTone> {
|
||||
let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y));
|
||||
let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect());
|
||||
let mut eligible = Vec::new();
|
||||
let (mut colour_index, mut extra_index) = (0, 0);
|
||||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||||
continue;
|
||||
}
|
||||
// About one 8-bit sRGB code above black: don't learn a flat black segment from
|
||||
// JPEG compression. The sensor proxy averages many samples before this test.
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5)
|
||||
|| !output.iter().all(|v| v.is_finite() && *v >= 0.0)
|
||||
|| !(0.0003..0.85).contains(&luminance_2020(*output))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A colour holdout must never enter tone training just because the wider sample set
|
||||
// changes its index. Split the extra samples separately, keeping every colour index.
|
||||
let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index };
|
||||
let held = *index % 3 == 0;
|
||||
*index += 1;
|
||||
let (x, y) = luminances(input.map(f64::from), output.map(f64::from));
|
||||
if x.is_finite() && x > 0.0 {
|
||||
eligible.push((x, y, held));
|
||||
}
|
||||
}
|
||||
let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count();
|
||||
let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect();
|
||||
// Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank,
|
||||
// so a single quantized grey also keeps the original curve.
|
||||
if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION {
|
||||
return original;
|
||||
}
|
||||
let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect();
|
||||
let Some(candidate) = fit.fit(training) else { return original };
|
||||
let error = |tone: &CameraTone| -> f64 {
|
||||
held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum()
|
||||
};
|
||||
// A wider histogram must also help independent dark pixels, not just redistribute the
|
||||
// knots. The usual held-out gates still check the completed look on the colour pairs.
|
||||
if let Some(old) = original {
|
||||
let (before, after) = (error(&old), error(&candidate));
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}");
|
||||
}
|
||||
if !after.is_finite() || after >= before * 0.99 {
|
||||
return original;
|
||||
}
|
||||
}
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
|
||||
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||||
let mut gram = [[0.0; 3]; 3];
|
||||
@@ -705,9 +852,7 @@ fn fit_pairs_on(
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let Some(curve) = tone_fit.fit(tone_pairs) else { continue };
|
||||
let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue };
|
||||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
@@ -849,9 +994,8 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
|
||||
})),
|
||||
None => Mat3::IDENTITY,
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None };
|
||||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?;
|
||||
let mut look = CameraLook { matrix, tone, hue_sat: None };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
}
|
||||
@@ -957,9 +1101,8 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() };
|
||||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
|
||||
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
|
||||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||||
@@ -1317,6 +1460,167 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn steep_toe_scene() -> (Rgb32f, Rgb32f) {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
// A broad scene with a steep camera toe below the old JPEG floor of 0.015.
|
||||
// Permute brightness so the held-out pixels span the same range independently.
|
||||
let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25;
|
||||
let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025];
|
||||
*src = rgb.map(|v| v * y / luminance_2020(rgb));
|
||||
let display = 3.0 * y * (y / (y + 0.02)).powi(2);
|
||||
*dst = src.map(|v| v * display / y);
|
||||
}
|
||||
(sensor, reference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_follows_the_jpegs_deep_shadows() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap();
|
||||
let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
|
||||
assert!(dark.len() > 256);
|
||||
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
|
||||
assert!(error < 0.001, "deep-shadow luminance error {error}");
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
let fit = |reference: &Rgb32f| {
|
||||
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap();
|
||||
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
|
||||
};
|
||||
let expected = fit(&reference);
|
||||
let mut changed = reference.clone();
|
||||
let mut index = 0;
|
||||
for (x, y) in sensor.data.iter().zip(&mut changed.data) {
|
||||
if colour_pair(*x, *y) {
|
||||
if index % 3 == 0 {
|
||||
*y = y.map(|v| v * 1.1);
|
||||
assert!(colour_pair(*x, *y));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(fit(&changed), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_toe_is_robust_to_shadow_noise() {
|
||||
let (sensor, clean) = steep_toe_scene();
|
||||
let mut noisy = clean.clone();
|
||||
for (i, p) in noisy.data.iter_mut().enumerate() {
|
||||
if luminance_2020(*p) < 0.015 {
|
||||
// Quantized JPEG levels with bounded noise and occasional compression outliers.
|
||||
let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004;
|
||||
*p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003);
|
||||
if i % 23 == 0 {
|
||||
*p = [0.014; 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap();
|
||||
let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
|
||||
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
|
||||
assert!(error < 0.001, "noisy shadow luminance error {error}");
|
||||
assert_eq!(look.tone.apply(0.0), 0.0);
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
for i in 1..1000 {
|
||||
assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_toe_requires_lower_independent_dark_error() {
|
||||
for holdout_matches_original in [true, false] {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 };
|
||||
*src = [y; 3];
|
||||
// Midtones teach the original slope of 3. Dark training teaches 0.3,
|
||||
// but the independent dark holdout can agree with either slope.
|
||||
let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 };
|
||||
*dst = [y * slope; 3];
|
||||
assert_eq!(colour_pair(*src, *dst), i >= 600);
|
||||
}
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap();
|
||||
let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap();
|
||||
// Both sample sets start at a multiple of three, so this split matches
|
||||
// the helper's separate colour and extra-sample indices.
|
||||
let candidate = fit_tone(
|
||||
sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 3 != 0)
|
||||
.map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from))))
|
||||
.collect(),
|
||||
)
|
||||
.unwrap();
|
||||
let held: Pairs =
|
||||
sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect();
|
||||
assert_eq!(held.len(), 200);
|
||||
assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y))));
|
||||
assert!(rank_correlation_ties(&held) > 0.99);
|
||||
let error = |tone: &CameraTone| {
|
||||
held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::<f64>()
|
||||
};
|
||||
assert_ne!(candidate, original);
|
||||
let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap();
|
||||
if holdout_matches_original {
|
||||
assert!(error(&candidate) > error(&original) + 0.1);
|
||||
assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve");
|
||||
} else {
|
||||
assert!(error(&candidate) < error(&original) * 0.99);
|
||||
assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate");
|
||||
}
|
||||
let mut previous = 0.0;
|
||||
assert_eq!(selected.apply(0.0), 0.0);
|
||||
for i in 0..10_000 {
|
||||
let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0));
|
||||
assert!(y.is_finite() && y >= previous && y <= 1.0);
|
||||
previous = y;
|
||||
}
|
||||
assert_eq!(selected.apply(f32::NAN), 0.0);
|
||||
assert_eq!(selected.apply(f32::INFINITY), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreliable_dark_pairs_keep_the_original_tone_and_colour() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect();
|
||||
let mut excluded = sensor.clone();
|
||||
for &i in &dark {
|
||||
excluded.data[i] = [f32::NAN; 3];
|
||||
}
|
||||
let colour = Some((Mat3::IDENTITY, None));
|
||||
let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap();
|
||||
for case in 0..6 {
|
||||
let (mut x, mut y) = (sensor.clone(), reference.clone());
|
||||
for (j, &i) in dark.iter().enumerate() {
|
||||
match case {
|
||||
0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal
|
||||
1 => x.data[i] = [0.00001; 3], // below the sensor noise floor
|
||||
2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs
|
||||
3 => x.data[i] = [0.003; 3], // no scene luminance spread
|
||||
4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe
|
||||
5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let look = fit_pairs_with(&x, &y, colour.clone()).unwrap();
|
||||
assert_eq!(look.tone, original.tone, "case {case}");
|
||||
assert_eq!(look.matrix, original.matrix, "case {case}");
|
||||
assert!(look.hue_sat.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
|
||||
// An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
|
||||
@@ -1834,17 +2138,19 @@ mod tests {
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let own = lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some();
|
||||
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
|
||||
let measured = lightcraft_raw::spectral::find(make, model).is_some();
|
||||
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
|
||||
let before = raw.color.clone();
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes);
|
||||
let spectral = !own && fitted.is_none() && covered;
|
||||
let spectral = !own && fitted.is_none() && measured;
|
||||
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
|
||||
assert_eq!(raw.color != before, spectral, "{name}");
|
||||
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
|
||||
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
|
||||
// spectral matrices, whichever look it got (issue #730)
|
||||
// white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin,
|
||||
// through the spectral matrices or its presets, whichever look it got (issue #730)
|
||||
assert_eq!(info.relative_wb, !own && !covered, "{name}");
|
||||
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
|
||||
if covered && !own {
|
||||
@@ -1885,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
|
||||
@@ -2062,6 +2369,43 @@ mod tests {
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
|
||||
/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same
|
||||
/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation
|
||||
/// table and its tone curve; without a look, those of the camera transform.
|
||||
#[test]
|
||||
fn dng_colour_renders_like_the_raws_starting_colour() {
|
||||
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
|
||||
let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37));
|
||||
assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb);
|
||||
let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
|
||||
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
|
||||
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
|
||||
for look in [Some(&look), None] {
|
||||
let dng = dng_colour(&ColorData::default(), &t, look).unwrap();
|
||||
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
|
||||
for i in 0..3 {
|
||||
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
|
||||
}
|
||||
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
|
||||
for c in samples {
|
||||
let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c));
|
||||
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}");
|
||||
}
|
||||
assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone()));
|
||||
let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve);
|
||||
match look {
|
||||
Some(look) => {
|
||||
let tone = tone.expect("the look's tone curve");
|
||||
let worst =
|
||||
(0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max);
|
||||
// (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here)
|
||||
assert!(worst < 4e-3, "{worst}");
|
||||
}
|
||||
None => assert!(tone.is_none()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
|
||||
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
|
||||
CameraLook {
|
||||
@@ -2317,6 +2661,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sony ARWs without measured matrices read Kelvin from their white-balance presets
|
||||
/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and
|
||||
/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its
|
||||
/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files.
|
||||
#[test]
|
||||
fn corpus_arw_white_balance_in_kelvin_through_the_presets() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
};
|
||||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
|
||||
assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}");
|
||||
assert!(
|
||||
(info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0,
|
||||
"{name}: {} {}",
|
||||
info.as_shot_temp,
|
||||
info.as_shot_tint
|
||||
);
|
||||
let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default());
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
@@ -2810,4 +3184,20 @@ mod tests {
|
||||
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
|
||||
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
|
||||
}
|
||||
|
||||
/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted.
|
||||
#[test]
|
||||
fn corpus_arw6_full_frame_gets_a_camera_look() {
|
||||
const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) };
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/arw6-sony-ilce7rm6-ff-land-c.arw");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -303,9 +303,20 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
|
||||
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
|
||||
}
|
||||
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
|
||||
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let sibling = src.with_file_name("denoise-model.json");
|
||||
let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else {
|
||||
let why = if sibling.is_file() {
|
||||
"denoise-model.json could not be read or is too large"
|
||||
} else if src.with_file_name("model.json").is_file() {
|
||||
// other apps ship their models with a model.json of their own (#1381)
|
||||
"No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \
|
||||
does not read: it needs its own denoise-model.json (see docs/denoise.md)"
|
||||
} else {
|
||||
"No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)"
|
||||
};
|
||||
return Err(bad(C, why));
|
||||
};
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
|
||||
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
),
|
||||
@@ -1079,15 +1111,47 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = active(s, "history.clear")?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }];
|
||||
s.commit("Clear History", Op::SetHistory { id, history })?;
|
||||
s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| {
|
||||
let id = active(s, "history.restore")?;
|
||||
let i = f64_req(p, "index", "history.restore")? as usize;
|
||||
let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?;
|
||||
s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?;
|
||||
ok()
|
||||
cmd!(
|
||||
"history.restore",
|
||||
"Go to History Step",
|
||||
[],
|
||||
None,
|
||||
"{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "history.restore";
|
||||
let id = active(s, c)?;
|
||||
let i = f64_req(p, "index", c)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(c, "no such step"));
|
||||
}
|
||||
go_to_history_step(s, c, id, i as usize)
|
||||
}
|
||||
),
|
||||
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
|
||||
let c = "history.previous";
|
||||
let id = active(s, c)?;
|
||||
let i = s
|
||||
.catalog
|
||||
.photo(id)
|
||||
.and_then(|ph| ph.history_index())
|
||||
.and_then(|i| i.checked_sub(1))
|
||||
.ok_or_else(|| bad(c, "at the first History step"))?;
|
||||
go_to_history_step(s, c, id, i)
|
||||
}),
|
||||
cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| {
|
||||
let c = "history.next";
|
||||
let id = active(s, c)?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let i = ph
|
||||
.history_index()
|
||||
.map(|i| i.saturating_add(1))
|
||||
.filter(|i| *i < ph.history.len())
|
||||
.ok_or_else(|| bad(c, "at the latest History step"))?;
|
||||
go_to_history_step(s, c, id, i)
|
||||
}),
|
||||
// ---- interactions (slider drags, brush strokes)
|
||||
cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| {
|
||||
@@ -1104,3 +1168,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later
|
||||
/// steps stay listed (one undo step).
|
||||
fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result<Value> {
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?;
|
||||
let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i);
|
||||
let label = format!("History: {}", st.label);
|
||||
let edited = Some((s.clock)());
|
||||
s.commit(
|
||||
&label,
|
||||
Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] },
|
||||
)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn has_history_before(s: &Session) -> std::result::Result<(), String> {
|
||||
let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index());
|
||||
if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) }
|
||||
}
|
||||
|
||||
fn has_history_after(s: &Session) -> std::result::Result<(), String> {
|
||||
let photo = s.active().and_then(|id| s.catalog.photo(id));
|
||||
if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("at the latest History step".into())
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -219,6 +361,12 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
|
||||
Ok(json!({"active": vis[next].0}))
|
||||
}
|
||||
|
||||
/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input
|
||||
/// can't overflow).
|
||||
fn row_step(p: &Value) -> isize {
|
||||
p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let mut ops = Vec::new();
|
||||
@@ -226,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"));
|
||||
}
|
||||
}
|
||||
@@ -641,6 +792,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),
|
||||
cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)),
|
||||
cmd!(
|
||||
"library.down",
|
||||
"Photo Below",
|
||||
[],
|
||||
Some("Down"),
|
||||
"{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}",
|
||||
always,
|
||||
|s, p| { step(s, row_step(p)) }
|
||||
),
|
||||
cmd!(
|
||||
"library.up",
|
||||
"Photo Above",
|
||||
[],
|
||||
Some("Up"),
|
||||
"{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}",
|
||||
always,
|
||||
|s, p| { step(s, -row_step(p)) }
|
||||
),
|
||||
// ---- rating / flags / labels
|
||||
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
|
||||
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
|
||||
@@ -1221,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") {
|
||||
@@ -1234,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!(
|
||||
@@ -1247,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
|
||||
@@ -1261,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 {
|
||||
@@ -1280,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
|
||||
@@ -1297,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", ¶ms)?;
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -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(¤t);
|
||||
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(¤t, &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()) {
|
||||
|
||||
@@ -195,20 +195,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| {
|
||||
Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect()))
|
||||
}),
|
||||
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| {
|
||||
Ok(Value::Array(
|
||||
crate::presets::PROFILES
|
||||
.iter()
|
||||
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)}))
|
||||
.collect(),
|
||||
))
|
||||
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile (built-in, then imported LUTs marked `imported`) with its group and favourite flag", always, |s, _| {
|
||||
let favorite = |id: &str| s.profile_favorites.iter().any(|f| f == id);
|
||||
let builtin = crate::presets::PROFILES.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(p.id)}));
|
||||
// imported LUT profiles too (#618), marked as in `profiles.menu`
|
||||
let imported =
|
||||
s.lut_profiles.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(&p.id), "imported": true}));
|
||||
Ok(Value::Array(builtin.chain(imported).collect()))
|
||||
}),
|
||||
cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())),
|
||||
cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| {
|
||||
cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| {
|
||||
let id = photo_arg(s, p, "history.list")?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?;
|
||||
Ok(json!({
|
||||
"history": ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"cursor": ph.history_index(),
|
||||
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -49,6 +49,10 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Read Metadata from File", Op::Batch { ops })?;
|
||||
// Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had
|
||||
// only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's
|
||||
// next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them.
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
let mut result = json!({"read": read, "failed": failed});
|
||||
if !warnings.is_empty() {
|
||||
@@ -90,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Read Metadata from File",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
has_selection,
|
||||
read
|
||||
),
|
||||
|
||||
+228
-14
@@ -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(¢red_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);
|
||||
|
||||
@@ -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()));
|
||||
|
||||
+114
-7
@@ -28,6 +28,8 @@ pub enum ExportFormat {
|
||||
Original,
|
||||
/// A DNG of the raw data (raw photos only), the edits embedded as XMP.
|
||||
Dng,
|
||||
/// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in.
|
||||
RenderedDng,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
@@ -42,6 +44,7 @@ impl ExportFormat {
|
||||
"jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl,
|
||||
"original" => Self::Original,
|
||||
"dng" => Self::Dng,
|
||||
"rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -56,7 +59,7 @@ impl ExportFormat {
|
||||
Self::Jxl => "jxl",
|
||||
// the original keeps its own extension (see `ExportOptions::file_name_for`)
|
||||
Self::Original => "",
|
||||
Self::Dng => "dng",
|
||||
Self::Dng | Self::RenderedDng => "dng",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,6 +626,32 @@ pub enum MetadataPolicy {
|
||||
None,
|
||||
}
|
||||
|
||||
/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or
|
||||
/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HdrCurve {
|
||||
#[default]
|
||||
Pq,
|
||||
Hlg,
|
||||
}
|
||||
|
||||
impl HdrCurve {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"pq" => Some(Self::Pq),
|
||||
"hlg" => Some(Self::Hlg),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn codec(self) -> lightcraft_codecs::HdrTransfer {
|
||||
match self {
|
||||
Self::Pq => lightcraft_codecs::HdrTransfer::Pq,
|
||||
Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportOptions {
|
||||
@@ -681,6 +710,8 @@ pub struct ExportOptions {
|
||||
pub add_to_photos: bool,
|
||||
/// The Photos album they go into, made when missing (empty = none).
|
||||
pub photos_album: String,
|
||||
/// The curve of an HDR AVIF: PQ (default) or HLG.
|
||||
pub hdr_transfer: HdrCurve,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -710,6 +741,7 @@ impl Default for ExportOptions {
|
||||
jxl_progressive: false,
|
||||
add_to_photos: false,
|
||||
photos_album: String::new(),
|
||||
hdr_transfer: HdrCurve::Pq,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,6 +783,7 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"jxlProgressive",
|
||||
"addToPhotos",
|
||||
"photosAlbum",
|
||||
"hdrTransfer",
|
||||
];
|
||||
|
||||
/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the
|
||||
@@ -923,6 +956,7 @@ impl ExportOptions {
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
@@ -1061,6 +1095,7 @@ impl ExportOptions {
|
||||
jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive),
|
||||
add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos),
|
||||
photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()),
|
||||
hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1168,8 @@ impl ExportOptions {
|
||||
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Tiff, _) => OutputDepth::U16,
|
||||
(ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::RenderedDng, _) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Psd, Some(8)) => OutputDepth::U8,
|
||||
(ExportFormat::Psd, _) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
@@ -1151,6 +1188,7 @@ impl ExportOptions {
|
||||
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
|
||||
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
|
||||
ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")],
|
||||
ExportFormat::RenderedDng => &[(16, "16-bit float")],
|
||||
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
|
||||
ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")],
|
||||
}
|
||||
@@ -1341,6 +1379,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta),
|
||||
f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
|
||||
ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1431,6 +1470,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met
|
||||
|
||||
/// Encode a high-bit-depth image (see [`encode_rendered`]).
|
||||
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let meta_src = meta;
|
||||
let img = finish_deep(img, o);
|
||||
let profile = match img.samples {
|
||||
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
|
||||
@@ -1449,8 +1489,12 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
|
||||
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
(ExportFormat::Psd, _) => encode::encode_psd(&e, &meta),
|
||||
(ExportFormat::RenderedDng, DeepSamples::F32(v)) => {
|
||||
let m = meta_src.cloned().unwrap_or_default();
|
||||
return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string());
|
||||
}
|
||||
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
|
||||
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
|
||||
lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec())
|
||||
}
|
||||
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
(ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => {
|
||||
@@ -1536,9 +1580,11 @@ pub struct Exported {
|
||||
/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`).
|
||||
pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) {
|
||||
let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||||
// whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie
|
||||
let (w, h) = (w.round().max(1.0), h.round().max(1.0));
|
||||
match &o.resize {
|
||||
Some(r) => r.apply(w, h),
|
||||
None => ((w.round() as usize).max(1), (h.round() as usize).max(1)),
|
||||
None => (w as usize, h as usize),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1688,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,
|
||||
@@ -2696,6 +2742,47 @@ mod tests {
|
||||
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_size_survives_the_render() {
|
||||
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
|
||||
let cropped = |r: Resize, w: u32, h: u32, x1: f64| {
|
||||
let mut p = named("IMG.ARW");
|
||||
(p.width, p.height) = (w, h);
|
||||
std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0);
|
||||
let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() });
|
||||
lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh))
|
||||
};
|
||||
let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0);
|
||||
let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true };
|
||||
// a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5
|
||||
let hair = 1.0 - 1e-9;
|
||||
assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365));
|
||||
assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000));
|
||||
// 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says
|
||||
for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] {
|
||||
for (w, h) in [(w, h), (h, w)] {
|
||||
for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] {
|
||||
for r in [
|
||||
Resize::long_edge(value as u32),
|
||||
mode(ResizeMode::ShortEdge, value, 0),
|
||||
mode(ResizeMode::Width, value, 0),
|
||||
mode(ResizeMode::Height, value, 0),
|
||||
mode(ResizeMode::Dimensions, value, 1365),
|
||||
mode(ResizeMode::Megapixels, value / 1000.0, 0),
|
||||
mode(ResizeMode::Percent, value / 100.0, 0),
|
||||
] {
|
||||
let want = r.apply(w.into(), h.into());
|
||||
assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
|
||||
fn p3_green(space: OutputSpace) -> Rgba8 {
|
||||
use lightcraft_color::{DISPLAY_P3, REC2020};
|
||||
@@ -3109,6 +3196,26 @@ mod tests {
|
||||
}
|
||||
|
||||
// Issue #496: photos rendered side by side give the same files, names, order and progress as
|
||||
// Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture
|
||||
#[test]
|
||||
fn rendered_dng_matches_the_rendered_tiff() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.visible()[0];
|
||||
let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap();
|
||||
assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name);
|
||||
let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap();
|
||||
let raw = lightcraft_raw::decode(&dng.bytes).unwrap();
|
||||
assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3));
|
||||
assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one");
|
||||
let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
|
||||
let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image;
|
||||
assert_eq!((reference.width, reference.height), (back.width, back.height));
|
||||
// mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB
|
||||
let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::<f32>() / back.data.len() as f32;
|
||||
let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::<f32>() / reference.data.len() as f32;
|
||||
assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}");
|
||||
}
|
||||
|
||||
// one at a time, and a cancel stops at the same photo
|
||||
#[test]
|
||||
fn side_by_side_batches_match_one_at_a_time() {
|
||||
|
||||
+405
-26
@@ -176,11 +176,13 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
kind: MediaKind::Raw,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
file_modified: None,
|
||||
meta,
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: None,
|
||||
orientation: Some(raw.orientation),
|
||||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
@@ -207,12 +209,14 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
kind: MediaKind::Image,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
file_modified: None,
|
||||
meta,
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
embedded_lens: None,
|
||||
xmp: None,
|
||||
preview_only: None,
|
||||
orientation: Some(o),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -508,15 +512,16 @@ fn load_bytes_now(
|
||||
///
|
||||
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
|
||||
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
|
||||
/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look
|
||||
/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||||
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
|
||||
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
|
||||
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||||
struct WbScaleOf {
|
||||
own_matrix: bool,
|
||||
relative: bool,
|
||||
/// Kelvin through the camera's measured matrices: edited on the relative scale before those were used.
|
||||
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
|
||||
legacy_relative: bool,
|
||||
/// The as-shot white through the measured matrices, when that is where the scale comes from.
|
||||
spectral_white: Option<lightcraft_color::Xy>,
|
||||
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
|
||||
scale_white: Option<lightcraft_color::Xy>,
|
||||
}
|
||||
|
||||
impl WbScaleOf {
|
||||
@@ -528,18 +533,26 @@ impl WbScaleOf {
|
||||
) -> Self {
|
||||
let own_matrix = lightcraft_raw::color::has_matrix(color);
|
||||
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
|
||||
let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten();
|
||||
let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers));
|
||||
WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white }
|
||||
let model = meta.model.as_deref().filter(|_| fitted);
|
||||
let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model));
|
||||
let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model));
|
||||
let scale_white = match camera {
|
||||
Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers),
|
||||
None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers),
|
||||
None => None,
|
||||
};
|
||||
let covered = camera.is_some() || presets;
|
||||
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
|
||||
}
|
||||
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
|
||||
/// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog
|
||||
/// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65.
|
||||
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
|
||||
/// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to
|
||||
/// `own`, D65.
|
||||
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
|
||||
if self.relative {
|
||||
return (6500.0, 0.0);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own));
|
||||
let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own));
|
||||
(t.round(), tint.round())
|
||||
}
|
||||
}
|
||||
@@ -643,9 +656,16 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
if preview_orientation(bytes, d.orientation).swaps_axes() {
|
||||
// headers only, as for other images (issue #367): decoding a full-size preview just for its
|
||||
// size took about a second per file in an import; a decode remains the fallback
|
||||
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
|
||||
Ok(header) => (header.width, header.height, header.orientation),
|
||||
Err(_) => {
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||||
(d.source_width, d.source_height, d.orientation)
|
||||
}
|
||||
};
|
||||
if preview_orientation(bytes, orientation).swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
Some((w, h))
|
||||
@@ -681,9 +701,10 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
|
||||
/// Filesystem-backed embedded-preview hook (native).
|
||||
///
|
||||
/// The quick path reads the file's header and its embedded preview and nothing else (see
|
||||
/// [`preview_window`]); a format whose preview the header cannot point at falls back to the
|
||||
/// whole-file path, which is what every format used to cost. The memory gate still counts the whole
|
||||
/// file, which is what the decode of one can hold.
|
||||
/// [`preview_window`]); a container the header cannot point into is planned from its tags
|
||||
/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a
|
||||
/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts
|
||||
/// the whole file, which is what the decode of one can hold.
|
||||
pub fn fs_preview_loader() -> PreviewLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
@@ -706,6 +727,10 @@ pub struct PreviewRead {
|
||||
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
|
||||
/// every preview cost before the header path existed).
|
||||
pub header_path: bool,
|
||||
/// Whether the sparse planner named the ranges, rather than a format's own header fields: the
|
||||
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
|
||||
/// the header path, and on the whole-file fallback).
|
||||
pub planned: bool,
|
||||
}
|
||||
|
||||
impl PreviewRead {
|
||||
@@ -745,7 +770,13 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
{
|
||||
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
|
||||
}
|
||||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) };
|
||||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else {
|
||||
// The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose
|
||||
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
|
||||
// sparse planner which ranges `embedded_preview` reads before handing the caller the
|
||||
// whole-file fallback.
|
||||
return planned_preview(&mut file, path, total);
|
||||
};
|
||||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||||
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
|
||||
@@ -767,7 +798,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true };
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
|
||||
// On a network share the bytes are the wait, so say what this preview actually cost. A window
|
||||
// taken from the prefix cost nothing beyond the prefix itself.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
@@ -776,6 +807,107 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
Ok(Some((prefix, window, read)))
|
||||
}
|
||||
|
||||
/// The most times the sparse planner may name ranges before the preview gives up: the plan is
|
||||
/// monotone (it never asks for a range twice), so a real file needs a handful.
|
||||
const MAX_PREVIEW_PLAN_ROUNDS: usize = 8;
|
||||
/// Tenths of a file the planner may have read before the preview gives up and the whole-file path
|
||||
/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the
|
||||
/// file has stopped being one.
|
||||
const MAX_PREVIEW_PLAN_SHARE: u64 = 6;
|
||||
/// The largest file the planner path builds its buffer for. The raws it serves are far below this;
|
||||
/// a file past it is read whole as before rather than allocating its length up front.
|
||||
const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30;
|
||||
/// The header the quick path is handed on the planner route. The header route grows its prefix to
|
||||
/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out
|
||||
/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough
|
||||
/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes.
|
||||
const PLAN_PREFIX: u64 = 64 * 1024;
|
||||
|
||||
/// The preview of a container the header-only path cannot answer for, from the byte ranges
|
||||
/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads.
|
||||
///
|
||||
/// The planner is asked about a buffer of the file's whole length in which only the ranges named so
|
||||
/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The
|
||||
/// buffer is the file's length, which the loader's memory gate already counts, and its untouched
|
||||
/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in
|
||||
/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so
|
||||
/// this path's cost is the bytes read, not a promise about RSS.
|
||||
///
|
||||
/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the
|
||||
/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it
|
||||
/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when
|
||||
/// the quick path could not reproduce what the whole-file path shows (the two container facts
|
||||
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
|
||||
/// gives what the whole file gives), so a window that still fails to decode ends in the same
|
||||
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
|
||||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
if total > MAX_PREVIEW_PLAN_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(len) = usize::try_from(total) else { return Ok(None) };
|
||||
let mut buf = vec![0u8; len];
|
||||
let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?;
|
||||
let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) };
|
||||
head.copy_from_slice(&header);
|
||||
let mut loaded = lightcraft_raw::sparse::Ranges::new();
|
||||
loaded.insert(0, header.len() as u64);
|
||||
for _ in 0..MAX_PREVIEW_PLAN_ROUNDS {
|
||||
match lightcraft_raw::sparse::preview_plan(&buf, &loaded) {
|
||||
lightcraft_raw::sparse::HeaderPlan::Need(ranges) => {
|
||||
let wanted = loaded.total().saturating_add(ranges.total());
|
||||
if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) {
|
||||
return Ok(None);
|
||||
}
|
||||
for &(start, end) in ranges.spans() {
|
||||
let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) };
|
||||
let Some(dst) = buf.get_mut(a..b) else { return Ok(None) };
|
||||
file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?;
|
||||
loaded.insert(start, end);
|
||||
}
|
||||
}
|
||||
lightcraft_raw::sparse::HeaderPlan::Ready => {
|
||||
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
|
||||
// The quick path hands the JPEG to the decoder alone, so what the *container* says
|
||||
// about the preview has to be something the bytes it is handed can show. Two container
|
||||
// facts change the pixels and are not in the JPEG: the preview's colour space (which
|
||||
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
|
||||
// no orientation of its own, the file's EXIF orientation. Where either is present and
|
||||
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
|
||||
// it already does for a container-declared colour space. A container that says sRGB
|
||||
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
|
||||
// space declines.
|
||||
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
|
||||
return Ok(None);
|
||||
}
|
||||
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
|
||||
// first: a JPEG that carries its own orientation needs nothing from the container.
|
||||
if lightcraft_meta::extract(&window).orientation.is_none()
|
||||
&& lightcraft_meta::extract(&header).orientation.is_none()
|
||||
&& lightcraft_meta::extract(&buf).orientation.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
|
||||
// A plan-driven preview is the one that used to cost the whole file, so it is worth
|
||||
// saying what it cost now too.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len());
|
||||
}
|
||||
// The planner's buffer is the file's length, so give its pages back the way a decode
|
||||
// does (see `fs_pair_loader`).
|
||||
drop(buf);
|
||||
crate::memory::release();
|
||||
return Ok(Some((header, window, read)));
|
||||
}
|
||||
lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
|
||||
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
|
||||
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
|
||||
@@ -856,11 +988,21 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let _permit = crate::memory::work_gate().acquire(len);
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
probe_bytes(path, &bytes)
|
||||
let mut info = probe_bytes(path, &bytes)?;
|
||||
info.file_modified = file_modified(path);
|
||||
Ok(info)
|
||||
});
|
||||
(loader, probe)
|
||||
}
|
||||
|
||||
/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the
|
||||
/// filesystem doesn't say or the time is before 1970.
|
||||
fn file_modified(path: &str) -> Option<String> {
|
||||
let t = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?;
|
||||
Some(crate::import::civil(secs))
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
|
||||
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
|
||||
@@ -908,28 +1050,49 @@ mod tests {
|
||||
let d65 = lightcraft_color::D65;
|
||||
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
|
||||
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some());
|
||||
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some());
|
||||
let (t, tint) = a7m3.as_shot(d65);
|
||||
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
|
||||
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
|
||||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(bare.legacy_relative && bare.spectral_white.is_none());
|
||||
assert!(bare.legacy_relative && bare.scale_white.is_none());
|
||||
assert_eq!(bare.as_shot(d65), {
|
||||
let (t, tint) = xy_to_temp_tint(d65);
|
||||
(t.round(), tint.round())
|
||||
});
|
||||
// a camera without measured matrices: relative, whatever the multipliers
|
||||
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
|
||||
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none());
|
||||
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none());
|
||||
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
|
||||
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
|
||||
// a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as
|
||||
// 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65)
|
||||
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
|
||||
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
|
||||
(2500.0, g(1336.0, 3672.0)),
|
||||
(3200.0, g(1720.0, 2620.0)),
|
||||
(4500.0, g(2256.0, 1880.0)),
|
||||
(6000.0, g(2680.0, 1544.0)),
|
||||
(8500.0, g(3136.0, 1300.0)),
|
||||
]);
|
||||
let with = ColorData { kelvin_presets: presets, ..Default::default() };
|
||||
let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2"));
|
||||
assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some());
|
||||
let (t, tint) = a99m2.as_shot(d65);
|
||||
assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2"));
|
||||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2"));
|
||||
assert!(bare.legacy_relative && bare.scale_white.is_none());
|
||||
// presets in a file of a camera not listed: relative, as the catalog decides from the camera alone
|
||||
let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1"));
|
||||
assert!(other.relative && other.scale_white.is_none());
|
||||
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
|
||||
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
|
||||
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
|
||||
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
|
||||
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none());
|
||||
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none());
|
||||
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
|
||||
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||||
@@ -967,6 +1130,7 @@ mod tests {
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||||
assert!(read.header_path, "the header path is what answered");
|
||||
assert!(!read.planned, "a RAF pointer in the header needs no planner");
|
||||
assert_eq!(read.total, file.len() as u64);
|
||||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
|
||||
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
|
||||
@@ -985,6 +1149,179 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the
|
||||
/// file's own orientation, and the EXIF colour space the container declares for its preview.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Fixture {
|
||||
dng: bool,
|
||||
sony: bool,
|
||||
orientation: Option<u16>,
|
||||
/// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB.
|
||||
color_space: Option<u16>,
|
||||
}
|
||||
|
||||
/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the
|
||||
/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of
|
||||
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
|
||||
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
|
||||
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut preview = IfdBuilder::new();
|
||||
if what.dng {
|
||||
preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||||
}
|
||||
if what.sony {
|
||||
preview.set(t::MAKE, Value::Ascii("SONY".into()));
|
||||
}
|
||||
if let Some(o) = what.orientation {
|
||||
preview.set(t::ORIENTATION, Value::Short(vec![o]));
|
||||
}
|
||||
if let Some(space) = what.color_space {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(0xA001, Value::Short(vec![space]));
|
||||
if space == 0xFFFF {
|
||||
let mut interop = IfdBuilder::new();
|
||||
interop.set(0x0001, Value::Undefined(b"R03".to_vec()));
|
||||
exif.set_child(t::INTEROP_IFD, interop);
|
||||
}
|
||||
preview.set_child(t::EXIF_IFD, exif);
|
||||
}
|
||||
preview.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
|
||||
TiffWriter::default().write(&[preview, raw]).expect("the fixture writes")
|
||||
}
|
||||
|
||||
/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview
|
||||
/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges
|
||||
/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels
|
||||
/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline
|
||||
/// rather than guess, so the caller keeps reading it whole.
|
||||
#[test]
|
||||
fn preview_loading_plans_the_ranges_it_needs() {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan");
|
||||
let path = dir.join("preview.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
|
||||
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(read.header_path, "and it is still a bounded read");
|
||||
assert_eq!(read.total, file.len() as u64);
|
||||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len());
|
||||
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The plan path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
|
||||
// The same structure without the tag that makes it a raw: the planner declines, and the
|
||||
// caller's whole-file read is what answers (no guessing at what a file might hold).
|
||||
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
|
||||
std::fs::write(&path, ¬_a_raw).unwrap();
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A file whose preview the quick path could not show the way the whole-file path does is left to
|
||||
/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is
|
||||
/// bigger than that header) and the preview JPEG carries none of its own, so a window read would
|
||||
/// come out unrotated where the whole-file path rotates it — the plan must decline instead of
|
||||
/// handing on a wrong picture.
|
||||
#[test]
|
||||
fn preview_loading_declines_an_orientation_only_the_whole_file_shows() {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
// the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts
|
||||
// IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image
|
||||
let mut strip = jpeg.clone();
|
||||
strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize));
|
||||
let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-orientation");
|
||||
let path = dir.join("rotated.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
|
||||
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
|
||||
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
|
||||
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
|
||||
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
|
||||
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
|
||||
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
|
||||
#[test]
|
||||
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
|
||||
use lightcraft_raw::PreviewColorSpace;
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-space");
|
||||
let path = dir.join("space.arw");
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
|
||||
std::fs::write(&path, &adobe).unwrap();
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
|
||||
|
||||
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
|
||||
std::fs::write(&path, &srgb).unwrap();
|
||||
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||||
assert!(read.planned, "the plan still answers");
|
||||
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
|
||||
}
|
||||
|
||||
/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as
|
||||
/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time.
|
||||
#[test]
|
||||
fn undated_files_take_the_file_date() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let rgb = vec![128u8; 16 * 16 * 3];
|
||||
let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let path = dir.join("export.jpg");
|
||||
std::fs::write(&path, &jpeg).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
|
||||
let want = crate::import::civil(mtime as i64);
|
||||
let (_, probe) = fs_hooks();
|
||||
let info = probe(&path).unwrap();
|
||||
assert_eq!(info.captured, None, "the file itself has no date");
|
||||
assert_eq!(info.file_modified.as_deref(), Some(want.as_str()));
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r.imported[0]);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
|
||||
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||||
/// file or one that isn't an image.
|
||||
@@ -1016,6 +1353,17 @@ mod tests {
|
||||
assert!(probe_bytes("x.jpg", b"not an image").is_err());
|
||||
}
|
||||
|
||||
/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets
|
||||
/// start from), not on the black its transparent pixels store.
|
||||
#[test]
|
||||
fn transparent_png_develops_on_white() {
|
||||
let rgba = [200, 40, 40, 255, 0, 0, 0, 0];
|
||||
let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap();
|
||||
let (img, _) = load_bytes(&png, 64).unwrap();
|
||||
assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0));
|
||||
assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0));
|
||||
}
|
||||
|
||||
/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample
|
||||
/// of a block on a grey surface must not become green speckles (the block's green used to take the
|
||||
/// clip level, which highlight reconstruction then kept).
|
||||
@@ -1825,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();
|
||||
@@ -1846,6 +2194,37 @@ mod tests {
|
||||
assert!(e.contains("without an embedded preview"), "{e}");
|
||||
}
|
||||
|
||||
/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606`
|
||||
/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic.
|
||||
#[test]
|
||||
fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let mos = |leaf_data: bool| {
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
|
||||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12]));
|
||||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1]));
|
||||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] });
|
||||
if leaf_data {
|
||||
ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat()));
|
||||
}
|
||||
TiffWriter::default().write(&[ifd0]).unwrap()
|
||||
};
|
||||
let p = probe_bytes("aptus22.mos", &mos(true)).unwrap();
|
||||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30));
|
||||
assert!(p.preview_only.is_some_and(|why| !why.is_empty()));
|
||||
let (img, src) = load_bytes(&mos(true), 20).unwrap();
|
||||
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
|
||||
// the same grey image without Leaf's block is an ordinary 16-bit TIFF
|
||||
let plain = probe_bytes("grey.tif", &mos(false)).unwrap();
|
||||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12));
|
||||
}
|
||||
|
||||
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
|
||||
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
|
||||
#[test]
|
||||
|
||||
+93
-15
@@ -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);
|
||||
}
|
||||
@@ -466,10 +470,18 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp
|
||||
|
||||
/// Probe files (headers + content hash) as an import would.
|
||||
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
|
||||
let progress = ScanProgress::default();
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
|
||||
}
|
||||
|
||||
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
|
||||
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
|
||||
/// `cancel` is set: the files not reached come back as errors.
|
||||
fn probe_all(
|
||||
probe: Option<&crate::media::FileProbe>,
|
||||
paths: &[String],
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Vec<Result<ProbeInfo, String>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let Some(probe) = probe.cloned() else {
|
||||
return paths
|
||||
@@ -497,11 +509,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
if i >= paths.len() {
|
||||
break;
|
||||
}
|
||||
if progress.cancel.load(Relaxed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
let r = probe(&paths[i]);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
done.fetch_add(1, Relaxed);
|
||||
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
|
||||
}
|
||||
});
|
||||
@@ -513,11 +525,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if progress.cancel.load(Relaxed) {
|
||||
if cancel.load(Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let r = probe(p);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
done.fetch_add(1, Relaxed);
|
||||
r
|
||||
})
|
||||
.collect()
|
||||
@@ -620,7 +632,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
progress.done.store(todo.len() - missing.len(), Relaxed);
|
||||
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter();
|
||||
let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
@@ -641,6 +653,8 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
Some(Ok(info)) => {
|
||||
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
|
||||
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
|
||||
// as the import will date it (a sidecar's date, when there is one, still wins there)
|
||||
c.captured = c.captured.or_else(|| info.file_modified.clone());
|
||||
c.preview_only = info.preview_only.clone();
|
||||
if let Some(h) = &info.content_hash {
|
||||
match seen_hash.get(h) {
|
||||
@@ -705,6 +719,10 @@ pub struct ImportJob {
|
||||
/// Files found so far (folders expanded) and handled so far, for progress.
|
||||
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
/// Of those, the files that have to be read first (not probed by a preceding scan) and the
|
||||
/// ones read so far: the step before they are handled.
|
||||
pub read_total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub read_done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
|
||||
@@ -857,6 +875,8 @@ impl ImportJob {
|
||||
now: (s.clock)(),
|
||||
total: Default::default(),
|
||||
done: Default::default(),
|
||||
read_total: Default::default(),
|
||||
read_done: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -917,8 +937,10 @@ impl ImportJob {
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
// Reading the files is the slow part of adding them where they are: it is counted on its
|
||||
// own, and `cancel` stops it too (a large library takes long enough to want both).
|
||||
self.read_total.fetch_add(missing.len(), Relaxed);
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
@@ -965,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
|
||||
@@ -974,6 +1000,10 @@ impl ImportJob {
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
// no date in the file or its sidecar: the file's own date, as Lightroom does
|
||||
if info.captured.is_none() {
|
||||
info.captured = info.file_modified.clone();
|
||||
}
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
@@ -1331,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());
|
||||
@@ -1498,4 +1529,51 @@ mod prepared_tests {
|
||||
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
|
||||
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
|
||||
}
|
||||
|
||||
fn unread_paths(n: usize) -> Vec<String> {
|
||||
(0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect()
|
||||
}
|
||||
|
||||
/// Reading the files of a large import (a Lightroom catalog's originals) reported nothing:
|
||||
/// the progress stood still at "14 of 24554" for as long as it took.
|
||||
#[test]
|
||||
fn prepare_counts_the_files_it_reads() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut s = Session::new();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)));
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
|
||||
let (done, last) = (job.read_done.clone(), done_in_probe.clone());
|
||||
job.probe = Some(std::sync::Arc::new(move |_: &str| {
|
||||
seen_in_probe.fetch_add(1, Relaxed);
|
||||
last.fetch_max(done.load(Relaxed), Relaxed);
|
||||
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
|
||||
}));
|
||||
let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false));
|
||||
assert_eq!(prepared.items.len(), 40);
|
||||
assert_eq!(seen.load(Relaxed), 40);
|
||||
assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read");
|
||||
assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted");
|
||||
assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own");
|
||||
}
|
||||
|
||||
/// Cancel had no effect until every file had been read.
|
||||
#[test]
|
||||
fn cancel_stops_reading_files() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut s = Session::new();
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
|
||||
let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone());
|
||||
job.probe = Some(std::sync::Arc::new(move |_: &str| {
|
||||
seen_in_probe.fetch_add(1, Relaxed);
|
||||
cancel_in_probe.store(true, Relaxed);
|
||||
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
|
||||
}));
|
||||
let prepared = job.prepare(&unread_paths(400), &cancel);
|
||||
// at most the files the (up to 8) readers had already started
|
||||
assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed));
|
||||
assert!(prepared.items.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -41,14 +41,25 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient
|
||||
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
|
||||
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
|
||||
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
|
||||
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
|
||||
let crop =
|
||||
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
|
||||
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
|
||||
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
|
||||
let corr = lens.correction(&shot)?;
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
|
||||
}
|
||||
|
||||
/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the
|
||||
/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C /
|
||||
/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor
|
||||
/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the
|
||||
/// difference is the tag's whole-millimetre rounding and the body's value stands.
|
||||
fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -> Option<f64> {
|
||||
let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c));
|
||||
match (camera, frame) {
|
||||
(Some(body), Some(frame)) if frame > body * 1.1 => Some(frame),
|
||||
(body, frame) => body.or(frame),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`.
|
||||
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
|
||||
crate::files::embedded_lens(raw).or_else(|| {
|
||||
@@ -98,6 +109,46 @@ mod tests {
|
||||
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
}
|
||||
|
||||
/// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the
|
||||
/// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's
|
||||
/// corners don't get the full frame's corner correction.
|
||||
#[test]
|
||||
fn in_camera_crop_mode_uses_the_frames_crop_factor() {
|
||||
let db = LensDb::parse(
|
||||
r#"<lensdatabase>
|
||||
<camera><maker>Sony</maker><model>ILCE-1M2</model><mount>Sony E</mount><cropfactor>1</cropfactor></camera>
|
||||
<lens><maker>Sigma</maker><model>24-70mm F2.8 DG DN | Art 019</model><mount>Sony E</mount><cropfactor>1.0</cropfactor>
|
||||
<calibration>
|
||||
<distortion model="ptlens" focal="70" a="0.0019947" b="0.0035263" c="0.0005016" />
|
||||
<vignetting model="pa" focal="70" aperture="2.8" distance="1000" k1="-0.4" k2="0" k3="0" />
|
||||
</calibration>
|
||||
</lens></lensdatabase>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let m = |f35: Option<f64>| lightcraft_meta::Metadata {
|
||||
make: Some("SONY".into()),
|
||||
model: Some("ILCE-1M2".into()),
|
||||
lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()),
|
||||
focal_length: Some(70.0),
|
||||
focal_length_35mm: f35,
|
||||
f_number: Some(2.8),
|
||||
..Default::default()
|
||||
};
|
||||
let at = |crop: f64, w: f64, h: f64| {
|
||||
let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h };
|
||||
db.lenses[0].correction(&shot).unwrap()
|
||||
};
|
||||
let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap();
|
||||
assert_eq!(aps_c, at(1.5, 5616.0, 3744.0));
|
||||
assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame");
|
||||
// full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor
|
||||
for f35 in [Some(70.0), Some(71.0), None] {
|
||||
assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}");
|
||||
}
|
||||
assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's");
|
||||
assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored");
|
||||
}
|
||||
|
||||
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
|
||||
/// stage with straight lines.
|
||||
#[test]
|
||||
|
||||
+111
-19
@@ -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;
|
||||
|
||||
@@ -130,7 +133,8 @@ fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. } => Some(*id),
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetHistoryCursor { id, .. } => Some(*id),
|
||||
Op::Batch { ops } if depth < 8 => {
|
||||
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
|
||||
let first = ids.next()??;
|
||||
@@ -175,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`]).
|
||||
@@ -210,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
|
||||
@@ -221,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`]).
|
||||
@@ -292,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,
|
||||
@@ -349,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(),
|
||||
@@ -389,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,
|
||||
@@ -784,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)?;
|
||||
@@ -839,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);
|
||||
@@ -881,11 +908,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
if self.source == LibrarySource::RecentlyAdded {
|
||||
// newest import first, whatever the sort (the grid groups by import day)
|
||||
// newest import first, whatever the sort direction (the grid groups by import day);
|
||||
// a batch stays in file order (name, then id)
|
||||
let cat = &self.catalog;
|
||||
visible.sort_by(|a, b| {
|
||||
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
|
||||
key(b).cmp(&key(a)).then(a.cmp(b))
|
||||
let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str());
|
||||
let by_name = match (name(a), name(b)) {
|
||||
(Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
};
|
||||
key(b).cmp(&key(a)).then(by_name).then(a.cmp(b))
|
||||
});
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
@@ -916,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();
|
||||
@@ -933,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();
|
||||
@@ -957,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;
|
||||
|
||||
@@ -980,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;
|
||||
@@ -999,10 +1083,14 @@ mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
#[cfg(test)]
|
||||
mod tests_history_cursor;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
#[cfg(test)]
|
||||
mod tests_invalidation;
|
||||
#[cfg(test)]
|
||||
mod tests_keyword_list;
|
||||
#[cfg(test)]
|
||||
mod tests_libops;
|
||||
@@ -1019,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;
|
||||
@@ -1029,4 +1119,6 @@ mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_sync;
|
||||
#[cfg(test)]
|
||||
mod tests_upright;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(©));
|
||||
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();
|
||||
|
||||
@@ -129,6 +129,65 @@ pub struct LightroomCompletion {
|
||||
/// Alias used by hosts that call the result a commit.
|
||||
pub type LightroomCommit = LightroomCompletion;
|
||||
|
||||
/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LightroomStep {
|
||||
/// Reading the catalog's tables.
|
||||
Catalog,
|
||||
/// Reading the photos the catalog names (headers, content hash): nearly all of the time.
|
||||
ReadPhotos,
|
||||
/// Readying the photos that were read for the catalog.
|
||||
AddPhotos,
|
||||
}
|
||||
|
||||
/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LightroomProgress {
|
||||
/// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]).
|
||||
pub catalog_total: Arc<AtomicUsize>,
|
||||
pub catalog_done: Arc<AtomicUsize>,
|
||||
read_total: Arc<AtomicUsize>,
|
||||
read_done: Arc<AtomicUsize>,
|
||||
add_total: Arc<AtomicUsize>,
|
||||
add_done: Arc<AtomicUsize>,
|
||||
/// The catalog is read: the photos are next.
|
||||
photos: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LightroomProgress {
|
||||
/// Reading the photos is nearly all of an import's time; the catalog and readying the photos
|
||||
/// take the rest of [`LightroomProgress::fraction`].
|
||||
const CATALOG_SHARE: f32 = 0.05;
|
||||
const READ_SHARE: f32 = 0.85;
|
||||
|
||||
/// The step under way, with how much of it is done and its total (0 while not yet known).
|
||||
pub fn step(&self) -> (LightroomStep, usize, usize) {
|
||||
let load = |n: &AtomicUsize| n.load(Ordering::Relaxed);
|
||||
if !self.photos.load(Ordering::Relaxed) {
|
||||
return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total));
|
||||
}
|
||||
let (read_done, read_total) = (load(&self.read_done), load(&self.read_total));
|
||||
let (add_done, add_total) = (load(&self.add_done), load(&self.add_total));
|
||||
// nothing to read (every photo is already in the library) goes straight to adding
|
||||
if read_done < read_total || (read_total == 0 && add_done == 0) {
|
||||
(LightroomStep::ReadPhotos, read_done, read_total)
|
||||
} else {
|
||||
(LightroomStep::AddPhotos, add_done, add_total)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole preparation, 0..=1.
|
||||
pub fn fraction(&self) -> f32 {
|
||||
let (step, done, total) = self.step();
|
||||
let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) };
|
||||
match step {
|
||||
LightroomStep::Catalog => Self::CATALOG_SHARE * part,
|
||||
LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part,
|
||||
LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lightroom import detached from the owner thread.
|
||||
pub struct LightroomJob {
|
||||
path: PathBuf,
|
||||
@@ -136,6 +195,7 @@ pub struct LightroomJob {
|
||||
import: ImportJob,
|
||||
token: LightroomLibraryToken,
|
||||
archive_dir: Option<PathBuf>,
|
||||
progress: LightroomProgress,
|
||||
}
|
||||
|
||||
impl LightroomJob {
|
||||
@@ -145,33 +205,25 @@ impl LightroomJob {
|
||||
let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
|
||||
let import = ImportJob::new(s, opts)?;
|
||||
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
|
||||
let progress = LightroomProgress {
|
||||
read_total: Arc::clone(&import.read_total),
|
||||
read_done: Arc::clone(&import.read_done),
|
||||
add_total: Arc::clone(&import.total),
|
||||
add_done: Arc::clone(&import.done),
|
||||
..LightroomProgress::default()
|
||||
};
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress })
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.import.total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.import.done.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(self.done(), self.total())
|
||||
/// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs.
|
||||
pub fn progress(&self) -> LightroomProgress {
|
||||
self.progress.clone()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.total)
|
||||
}
|
||||
|
||||
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.done)
|
||||
}
|
||||
|
||||
/// Finish an owner commit's deferred archive-index write on a worker.
|
||||
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
|
||||
finalization.finish()
|
||||
@@ -182,14 +234,12 @@ impl LightroomJob {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.total.store(1, Ordering::Relaxed);
|
||||
self.import.done.store(0, Ordering::Relaxed);
|
||||
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
|
||||
let mut data =
|
||||
lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
|
||||
lightroom_catalog::validate_for_job(&data).map_err(error)?;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.done.fetch_add(1, Ordering::Relaxed);
|
||||
let paths: Vec<String> = data
|
||||
.photos
|
||||
.iter()
|
||||
@@ -203,6 +253,9 @@ impl LightroomJob {
|
||||
.filter(|photo| !Path::new(&photo.path).is_file())
|
||||
.map(|photo| normalize(&photo.path))
|
||||
.collect();
|
||||
// the catalog step's last unit (validation, and looking for each original), then the photos
|
||||
self.progress.catalog_done.fetch_add(1, Ordering::Relaxed);
|
||||
self.progress.photos.store(true, Ordering::Relaxed);
|
||||
let normal = self.import.prepare(&paths, cancel);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
normal.rollback();
|
||||
@@ -279,6 +332,7 @@ mod tests {
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
stacks: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -322,6 +376,7 @@ mod tests {
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
stacks: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
@@ -335,6 +390,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One count for the whole import read "14 of 24554" while the photos were read, then twice
|
||||
/// the number of photos: each step has its own count, and the whole only moves forward.
|
||||
#[test]
|
||||
fn progress_names_each_step_with_its_own_count() {
|
||||
let p = LightroomProgress::default();
|
||||
let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed);
|
||||
assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0));
|
||||
assert_eq!(p.fraction(), 0.0);
|
||||
set(&p.catalog_total, 14);
|
||||
set(&p.catalog_done, 7);
|
||||
assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14));
|
||||
let mut last = p.fraction();
|
||||
assert!(last > 0.0);
|
||||
let mut forward = |p: &LightroomProgress| {
|
||||
let f = p.fraction();
|
||||
assert!(f >= last && f <= 1.0, "{f} after {last}");
|
||||
last = f;
|
||||
};
|
||||
set(&p.catalog_done, 14);
|
||||
p.photos.store(true, Ordering::Relaxed);
|
||||
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet");
|
||||
forward(&p);
|
||||
set(&p.add_total, 100);
|
||||
set(&p.read_total, 100);
|
||||
set(&p.read_done, 40);
|
||||
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100));
|
||||
forward(&p);
|
||||
set(&p.read_done, 100);
|
||||
set(&p.add_done, 25);
|
||||
assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100));
|
||||
forward(&p);
|
||||
set(&p.add_done, 100);
|
||||
forward(&p);
|
||||
assert!(p.fraction() > 0.999);
|
||||
// every photo already in the library: nothing to read
|
||||
let known = LightroomProgress::default();
|
||||
known.photos.store(true, Ordering::Relaxed);
|
||||
set(&known.add_total, 5);
|
||||
set(&known.add_done, 5);
|
||||
assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
|
||||
let mut session = crate::Session::new();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+241
-28
@@ -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 = 61;
|
||||
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];
|
||||
@@ -223,6 +223,13 @@ impl DecodedSource {
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Box<DecodedSource>),
|
||||
/// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of
|
||||
/// the photo open in the loupe is cut from its preview-level source instead of decoding the
|
||||
/// file again (a second or more for a 64 MP raw).
|
||||
Fit {
|
||||
from: Box<DecodedSource>,
|
||||
max_edge: usize,
|
||||
},
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
@@ -251,6 +258,13 @@ impl SourceRef {
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Fit { from, max_edge } => {
|
||||
if from.image.width.max(from.image.height) <= *max_edge {
|
||||
return Ok((**from).clone());
|
||||
}
|
||||
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
|
||||
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
|
||||
}
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
|
||||
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
|
||||
let plain = || match loader {
|
||||
@@ -314,6 +328,8 @@ pub struct MediaCache {
|
||||
full: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// How many full-resolution originals to keep (LRU, at least one).
|
||||
full_capacity: usize,
|
||||
/// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget.
|
||||
on_screen: Option<PhotoId>,
|
||||
/// How many previews to keep (LRU).
|
||||
pub preview_capacity: usize,
|
||||
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
|
||||
@@ -391,6 +407,7 @@ impl Default for MediaCache {
|
||||
previews: Vec::new(),
|
||||
full: Vec::new(),
|
||||
full_capacity: 1,
|
||||
on_screen: None,
|
||||
preview_capacity: 0,
|
||||
budget,
|
||||
file_loader: None,
|
||||
@@ -414,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +467,16 @@ impl MediaCache {
|
||||
self.trim_full();
|
||||
}
|
||||
|
||||
/// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays
|
||||
/// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it
|
||||
/// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once.
|
||||
pub fn set_on_screen(&mut self, id: Option<PhotoId>) {
|
||||
if self.on_screen != id {
|
||||
self.on_screen = id;
|
||||
self.enforce_budget();
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_full(&mut self) {
|
||||
while self.full.len() > self.full_capacity {
|
||||
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
|
||||
@@ -475,15 +505,20 @@ impl MediaCache {
|
||||
|
||||
/// Evict least recently used entries across the source caches and the rendered previews
|
||||
/// until they fit the budget. The newest preview-level source (the photo on screen, or the
|
||||
/// one just decoded) is never evicted here.
|
||||
/// one just decoded) is never evicted here, nor is the original of the photo on screen
|
||||
/// ([`Self::set_on_screen`]).
|
||||
pub fn enforce_budget(&mut self) {
|
||||
let mut held = self.held();
|
||||
// The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB)
|
||||
// nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and
|
||||
// thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step.
|
||||
let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum();
|
||||
let mut held = self.held().saturating_sub(pinned);
|
||||
while held > self.budget {
|
||||
let newest_preview = self.previews.iter().map(|e| e.2).max();
|
||||
let candidates = [
|
||||
self.thumbs.oldest_tick().map(|t| (t, 0)),
|
||||
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
|
||||
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
|
||||
self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)),
|
||||
self.rendered.oldest_tick().map(|t| (t, 3)),
|
||||
];
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
@@ -518,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)
|
||||
@@ -528,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,
|
||||
}
|
||||
@@ -625,6 +664,15 @@ impl MediaCache {
|
||||
if cached && let Some(a) = self.get_source(p.id, level) {
|
||||
return SourceRef::Loaded(Box::new(a));
|
||||
}
|
||||
// a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not
|
||||
// with a denoised picture, which the thumbnail would lose
|
||||
if cached
|
||||
&& level == SourceLevel::Thumb
|
||||
&& self.denoise.spec(p.id).is_none()
|
||||
&& let Some(big) = self.get_source(p.id, SourceLevel::Preview)
|
||||
{
|
||||
return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() };
|
||||
}
|
||||
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
|
||||
let max_edge = level.max_edge().min(match (&p.source, level) {
|
||||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
@@ -803,7 +851,31 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting
|
||||
/// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace
|
||||
/// it, so a few such panics used to stop every render).
|
||||
pub fn run(self) -> RenderResult {
|
||||
let failed = self.failed(String::new());
|
||||
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed })
|
||||
}
|
||||
|
||||
/// This job's result when it fails with `e`.
|
||||
fn failed(&self, e: String) -> RenderResult {
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_unguarded(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
{
|
||||
@@ -838,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);
|
||||
@@ -877,18 +962,7 @@ impl RenderJob {
|
||||
display,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
},
|
||||
Err(e) => self.failed(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,7 +1050,25 @@ pub struct QuickJob {
|
||||
}
|
||||
|
||||
impl QuickJob {
|
||||
/// The stand-in, from the first source that has one. A panic inside is this job's failure
|
||||
/// (see [`RenderJob::run`]).
|
||||
pub fn run(self) -> RenderResult {
|
||||
let (request_id, photo, key) = (self.request_id, self.photo, self.key);
|
||||
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult {
|
||||
request_id,
|
||||
source_key: None,
|
||||
photo,
|
||||
level: SourceLevel::Thumb,
|
||||
key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_unguarded(self) -> RenderResult {
|
||||
let (photo, key) = (self.photo, self.key);
|
||||
let request_id = self.request_id;
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
|
||||
@@ -1391,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);
|
||||
@@ -1457,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();
|
||||
@@ -1479,6 +1576,10 @@ pub struct ProbeInfo {
|
||||
pub kind: MediaKind,
|
||||
pub file_size: u64,
|
||||
pub captured: Option<String>,
|
||||
/// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the
|
||||
/// capture date of a file whose metadata has none, as Lightroom does (exports saved without
|
||||
/// metadata would otherwise all land under "Unknown Date").
|
||||
pub file_modified: Option<String>,
|
||||
pub meta: lightcraft_catalog::Meta,
|
||||
pub as_shot_wb: Option<(f64, f64)>,
|
||||
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
|
||||
@@ -1492,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>;
|
||||
@@ -1588,10 +1701,53 @@ mod thumbnail_hash_tests {
|
||||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0);
|
||||
}
|
||||
/// A panic while rendering (here in the decoder) used to end the pool's worker thread without
|
||||
/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now,
|
||||
/// for renders and for stand-ins, answering the request it came from.
|
||||
#[test]
|
||||
fn a_render_that_panics_fails_instead_of_never_answering() {
|
||||
let mut s = session();
|
||||
s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug")));
|
||||
let mut job = s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
job.request_id = 42;
|
||||
let (key, level, source_key) = (job.key, job.level, job.source_key);
|
||||
let r = job.run();
|
||||
assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key));
|
||||
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug"));
|
||||
let quick = QuickJob {
|
||||
request_id: 7,
|
||||
photo: PhotoId(1),
|
||||
key: 9,
|
||||
cached: Vec::new(),
|
||||
embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option<Rgba8> { panic!("preview reader bug") }), 256)),
|
||||
small: None,
|
||||
};
|
||||
let r = quick.run();
|
||||
assert_eq!((r.request_id, r.key), (7, 9));
|
||||
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
@@ -1793,6 +1949,45 @@ mod tests {
|
||||
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
|
||||
}
|
||||
|
||||
// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay
|
||||
// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render)
|
||||
#[test]
|
||||
fn the_on_screen_original_does_not_evict_the_other_sources() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
let mut m = MediaCache::default();
|
||||
let mb = 1 << 20;
|
||||
m.set_budget(20 * mb);
|
||||
m.set_on_screen(Some(PhotoId(1)));
|
||||
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
|
||||
m.insert(PhotoId(1), SourceLevel::Thumb, img(200));
|
||||
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
|
||||
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
|
||||
for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] {
|
||||
assert!(m.has_source(PhotoId(id), level), "{id} {level:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB):
|
||||
// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo
|
||||
// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown.
|
||||
#[test]
|
||||
fn the_original_on_screen_stays_even_over_budget() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
let mut m = MediaCache::default();
|
||||
let mb = 1 << 20;
|
||||
m.set_budget(20 * mb);
|
||||
m.set_on_screen(Some(PhotoId(1)));
|
||||
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
|
||||
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
|
||||
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
|
||||
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
|
||||
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
|
||||
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
|
||||
m.set_on_screen(Some(PhotoId(2)));
|
||||
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
|
||||
assert!(m.held() <= 20 * mb, "{}", m.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_gate_waits_for_room() {
|
||||
use crate::memory::WorkGate;
|
||||
@@ -1931,6 +2126,24 @@ mod tests {
|
||||
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
|
||||
}
|
||||
|
||||
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
|
||||
/// one nothing can decode) is not read again.
|
||||
#[test]
|
||||
fn a_thumbnail_is_cut_from_the_decoded_preview() {
|
||||
let mut s = crate::Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
|
||||
s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
|
||||
let job = s.thumb_job(p.id, 512).unwrap();
|
||||
assert!(matches!(job.source, SourceRef::Fit { .. }));
|
||||
let r = job.run();
|
||||
let thumb = r.rendered.as_ref().unwrap();
|
||||
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
|
||||
s.accept(&r);
|
||||
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
|
||||
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_source_follows_what_the_session_accepted() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
|
||||
@@ -110,7 +110,9 @@ impl MergeJob {
|
||||
.par_iter()
|
||||
.map(|(_, path)| {
|
||||
let bytes = (self.read)(path)?;
|
||||
let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}"));
|
||||
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
|
||||
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
|
||||
.map_err(|e| format!("{path}: {e}"));
|
||||
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
|
||||
f
|
||||
|
||||
@@ -23,6 +23,73 @@ pub struct PresetFile {
|
||||
pub presets: Vec<Preset>,
|
||||
}
|
||||
|
||||
/// The session's presets (built-in, then the user's) with a generation that changes on every
|
||||
/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
|
||||
/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
|
||||
///
|
||||
/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
|
||||
/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
|
||||
/// Generations are unique across all lists in the process.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PresetList {
|
||||
items: Vec<Preset>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn next_generation() -> u64 {
|
||||
PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
impl PresetList {
|
||||
pub fn new(items: Vec<Preset>) -> PresetList {
|
||||
PresetList { items, generation: next_generation() }
|
||||
}
|
||||
|
||||
/// Changes whenever the list may have changed.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PresetList {
|
||||
fn default() -> PresetList {
|
||||
PresetList::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for PresetList {
|
||||
type Target = Vec<Preset>;
|
||||
fn deref(&self) -> &Vec<Preset> {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for PresetList {
|
||||
fn deref_mut(&mut self) -> &mut Vec<Preset> {
|
||||
self.generation = next_generation();
|
||||
&mut self.items
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a PresetList {
|
||||
type Item = &'a Preset;
|
||||
type IntoIter = std::slice::Iter<'a, Preset>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.items.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a mut PresetList {
|
||||
type Item = &'a mut Preset;
|
||||
type IntoIter = std::slice::IterMut<'a, Preset>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.generation = next_generation();
|
||||
self.items.iter_mut()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
|
||||
pub fn slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
@@ -107,22 +174,33 @@ impl Session {
|
||||
/// Add imported presets. Identical presets (same name, group and settings) already present are
|
||||
/// skipped; id clashes get a fresh id. Returns the ids added.
|
||||
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
|
||||
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
|
||||
let mut by_name: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for (i, q) in self.presets.iter().enumerate() {
|
||||
by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
|
||||
}
|
||||
let mut added = Vec::new();
|
||||
for mut p in presets {
|
||||
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
|
||||
let key = (p.name.clone(), p.group.clone());
|
||||
let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
|
||||
if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
|
||||
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
|
||||
let mut id = base.clone();
|
||||
let mut n = 2;
|
||||
while self.presets.iter().any(|q| q.id == id) {
|
||||
while ids.contains(&id) {
|
||||
id = format!("{base}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
p.id = id;
|
||||
}
|
||||
p.builtin = false;
|
||||
ids.insert(p.id.clone());
|
||||
by_name.entry(key).or_default().push(self.presets.len());
|
||||
added.push(p.id.clone());
|
||||
self.presets.push(p);
|
||||
}
|
||||
|
||||
+88
-17
@@ -319,6 +319,17 @@ pub(crate) trait MoveFs {
|
||||
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
|
||||
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
|
||||
/// The folder lists an entry spelled exactly like `p` (letter case included). On a
|
||||
/// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two
|
||||
/// apart. When the folder can't be listed: `true`, so nothing is skipped.
|
||||
fn listed(&self, p: &Path) -> bool {
|
||||
let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true };
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries.flatten().any(|e| e.file_name() == name),
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
/// Content observed in a packet, independent of its pathname or inode. Model volumes
|
||||
/// override this; disk implementations stream a bounded buffer without retaining the XML.
|
||||
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
|
||||
@@ -366,15 +377,23 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// no stable file id in std here: `b` is `a` under another spelling when they look
|
||||
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
|
||||
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
|
||||
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
|
||||
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
// no stable file id in std here: `a` and `b` are one file under two spellings when
|
||||
// they look alike and the folder doesn't list both (on a case-sensitive folder
|
||||
// holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the
|
||||
// listed one: the answer must not depend on the argument order.
|
||||
let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false };
|
||||
let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase();
|
||||
if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return false };
|
||||
!entries.flatten().any(|e| e.file_name() == name)
|
||||
let (mut listed_a, mut listed_b) = (false, false);
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
listed_a |= name == name_a;
|
||||
listed_b |= name == name_b;
|
||||
}
|
||||
!(listed_a && listed_b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,15 +718,14 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu
|
||||
if fs.exists(t) && !is_respelling(fs, f, t) {
|
||||
return Err(fail(format!("{to} already exists")));
|
||||
}
|
||||
let mut packets: Vec<RetainedPacket> = sidecars(from)
|
||||
.into_iter()
|
||||
.map(|(p, _, _)| p)
|
||||
.filter(|p| fs.is_file(p))
|
||||
.map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path })
|
||||
.collect();
|
||||
// An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the
|
||||
// lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`.
|
||||
let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc));
|
||||
let mut packets: Vec<RetainedPacket> =
|
||||
sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect();
|
||||
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
|
||||
let mut done: Vec<SidecarMove> = Vec::new();
|
||||
let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| {
|
||||
let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| {
|
||||
let mut dst = crate::sidecar::sidecar_path(to, naming);
|
||||
if upper {
|
||||
dst = dst.with_extension("XMP");
|
||||
@@ -1124,6 +1142,24 @@ pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Windows has no file id in std: two spellings of one file are the same file whichever is
|
||||
/// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the
|
||||
/// known `Trip.xmp`), and only the spelling on disk is listed.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn same_file_spellings_are_symmetric_on_windows() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp"));
|
||||
std::fs::write(&real, b"packet").unwrap();
|
||||
std::fs::write(&other, b"packet").unwrap();
|
||||
assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real));
|
||||
assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias));
|
||||
assert!(RealFs.listed(&real) && !RealFs.listed(&alias));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// The cross-volume move's check compares files chunk by chunk: equal files across several
|
||||
/// chunks, a difference in the last chunk, different lengths, a missing file.
|
||||
#[test]
|
||||
@@ -1171,7 +1207,10 @@ pub(crate) mod tests {
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p })
|
||||
self.files.borrow().iter().position(|(f, _)| {
|
||||
let f = f.replace('\\', "/");
|
||||
if self.ci { folded_path(&f) == folded_path(&p) } else { f == p }
|
||||
})
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
pub fn listing(&self) -> Vec<(String, String)> {
|
||||
@@ -1196,7 +1235,7 @@ pub(crate) mod tests {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
@@ -1207,7 +1246,7 @@ pub(crate) mod tests {
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
@@ -1223,6 +1262,10 @@ pub(crate) mod tests {
|
||||
self.files.borrow_mut().remove(i);
|
||||
Ok(())
|
||||
}
|
||||
fn listed(&self, p: &Path) -> bool {
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p)
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = Self::norm(&sidecar.with_extension(""));
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
@@ -1293,7 +1336,7 @@ pub(crate) mod tests {
|
||||
} else {
|
||||
serde_json::json!({"path": from, "into": root.join("Archive")})
|
||||
};
|
||||
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") };
|
||||
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") };
|
||||
let before = s.catalog.to_snapshot();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
@@ -1421,6 +1464,34 @@ pub(crate) mod tests {
|
||||
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
|
||||
}
|
||||
|
||||
/// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed
|
||||
/// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling
|
||||
/// is the one reported and queued.
|
||||
#[test]
|
||||
fn case_insensitive_renames_keep_each_sidecars_spelling() {
|
||||
let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]);
|
||||
move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap();
|
||||
move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/c.jpg".into(), "A".into()),
|
||||
("/p/c.jpg.XMP".into(), "full".into()),
|
||||
("/p/c.xmp".into(), "stem".into()),
|
||||
("/p/d.jpg".into(), "B".into()),
|
||||
("/p/d.xmp".into(), "mixed".into())
|
||||
]
|
||||
);
|
||||
// the move of the sidecar's real spelling is the one attempted (and here fails): the photo
|
||||
// goes back and nothing changed
|
||||
let before = fs.listing();
|
||||
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into()];
|
||||
let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err();
|
||||
assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}");
|
||||
assert!(!e.moved && e.pending_sidecars.is_empty());
|
||||
assert_eq!(fs.listing(), before);
|
||||
}
|
||||
|
||||
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
|
||||
/// their new names (an undoable partial rename), so nothing goes missing.
|
||||
#[test]
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+129
-28
@@ -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 {
|
||||
@@ -566,7 +651,12 @@ impl Session {
|
||||
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
|
||||
// Keep an existing packet's spelling/style so foreign properties survive this save and
|
||||
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
|
||||
let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred);
|
||||
// On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder
|
||||
// lists comes first, so the atomic write doesn't rename the packet.
|
||||
use crate::rename::MoveFs;
|
||||
let existing: Vec<PathBuf> = candidates.into_iter().filter(|path| path.is_file()).collect();
|
||||
let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned();
|
||||
let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred);
|
||||
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
@@ -586,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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use crate::tests_xmp::{same_path_for_volume, same_path_or_file};
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -16,7 +17,7 @@ impl MoveFs for Faults {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.0.iter().any(|p| p == a) {
|
||||
if self.0.iter().any(|p| same_path_or_file(p, a)) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
@@ -261,8 +262,8 @@ fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() {
|
||||
assert!(!second_packet.exists());
|
||||
let pending = queues(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].from, shared);
|
||||
assert_eq!(pending[0].to, second_packet);
|
||||
assert!(same_path_for_volume(&pending[0].from, &shared), "{:?}", pending[0].from);
|
||||
assert!(same_path_for_volume(&pending[0].to, &second_packet), "{:?}", pending[0].to);
|
||||
let catalog_before = s.catalog.to_snapshot();
|
||||
let history_before = (s.undo.len(), s.redo.len(), s.commits());
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Native files and genuine Session entry points: packet ownership survives partial renames.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use crate::tests_xmp::same_path_or_file;
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -16,7 +17,7 @@ impl MoveFs for Faults {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.0.iter().any(|p| p == a) {
|
||||
if self.0.iter().any(|p| same_path_or_file(p, a)) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Real Session/serializer regressions for saves that overlap retained physical repairs.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use crate::tests_xmp::same_path_or_file;
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -19,14 +20,14 @@ impl MoveFs for PartialRename {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if a == self.sidecar || a == self.photo {
|
||||
if same_path_or_file(a, &self.sidecar) || same_path_or_file(a, &self.photo) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
}
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if a == self.sidecar {
|
||||
if same_path_or_file(a, &self.sidecar) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault"))
|
||||
} else {
|
||||
RealFs.copy_no_replace(a, b)
|
||||
@@ -151,7 +152,9 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw
|
||||
assert_eq!(parsed.title.as_deref(), Some("Final title"));
|
||||
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
|
||||
if upper {
|
||||
assert!(!preferred.exists(), "merge uses the repaired uppercase packet");
|
||||
// by listing: on a case-insensitive volume `preferred` answers for the upper-case packet
|
||||
let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
|
||||
assert!(!listed, "merge uses the repaired uppercase packet");
|
||||
}
|
||||
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
|
||||
s.execute(step, &json!({})).unwrap();
|
||||
|
||||
+92
-24
@@ -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())),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -81,11 +81,11 @@ fn quick_develop_reset_and_batch_paths_fit_too() {
|
||||
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
|
||||
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
|
||||
|
||||
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
|
||||
// back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388)
|
||||
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
|
||||
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
|
||||
assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0));
|
||||
assert!(g.is_within_image(w, h));
|
||||
}
|
||||
|
||||
@@ -106,3 +106,21 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(close(geometry(&s), start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_shrunk_by_straightening_grows_back_towards_0() {
|
||||
// issue #1388: slide the Straighten slider out and back, in one drag or in several
|
||||
let mut s = Session::with_demo();
|
||||
let (w, h) = dims(&s);
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
|
||||
for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] {
|
||||
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}");
|
||||
}
|
||||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||
s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap();
|
||||
s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}");
|
||||
}
|
||||
@@ -277,6 +277,29 @@ fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() {
|
||||
let _ = std::fs::remove_dir_all(&x.dir);
|
||||
}
|
||||
|
||||
/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then
|
||||
/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described.
|
||||
#[test]
|
||||
fn inspecting_another_apps_model_folder_says_what_is_missing() {
|
||||
let dir = temp_dir("denoise-foreign");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
|
||||
let src = dir.join("rawdenoise");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let onnx = src.join("rawdenoise.onnx");
|
||||
std::fs::write(&onnx, b"not an onnx file").unwrap();
|
||||
let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
|
||||
let e = inspect();
|
||||
assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}");
|
||||
std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap();
|
||||
let e = inspect();
|
||||
assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}");
|
||||
std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap();
|
||||
let e = inspect();
|
||||
assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() {
|
||||
let mut x = setup("background", true);
|
||||
|
||||
@@ -455,6 +455,38 @@ fn hdr_avif_export_is_pq_rec2020() {
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_avif_export_hlg() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70}));
|
||||
assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg);
|
||||
assert_eq!(o.to_json()["hdrTransfer"], "hlg");
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
// nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box
|
||||
let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box");
|
||||
assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]);
|
||||
assert!(!hdr.bytes.windows(4).any(|w| w == b"clli"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -498,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);
|
||||
}
|
||||
|
||||
@@ -514,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,21 @@ fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool
|
||||
})
|
||||
}
|
||||
|
||||
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
|
||||
fn slash(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
}
|
||||
|
||||
fn file_source(s: &Session, id: u64) -> String {
|
||||
let Source::File { path } = &s.catalog.photo(PhotoId(id)).unwrap().source else { panic!("synthetic file fixture") };
|
||||
slash(path)
|
||||
}
|
||||
|
||||
fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) {
|
||||
let files = fs.listing();
|
||||
for photo in s.catalog.photos() {
|
||||
let Source::File { path } = &photo.source else { panic!("synthetic file fixture") };
|
||||
assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0);
|
||||
assert!(files.iter().any(|(file, _)| *file == slash(path)), "photo {} points to missing {path}: {files:?}", photo.id.0);
|
||||
assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str());
|
||||
}
|
||||
let replayed = memory_library(store);
|
||||
@@ -102,7 +112,8 @@ fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal()
|
||||
let renamed = fs.listing();
|
||||
s.merge_undo(2, "Rename Twice");
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]);
|
||||
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
|
||||
assert_eq!(moves, vec![("/p/final.jpg".to_string(), "/p/a.jpg".to_string())]);
|
||||
// First exercise the ordinary merged step, before any corrective paths exist.
|
||||
for redo in [false, true] {
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
@@ -274,7 +285,7 @@ fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollb
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
let mut after = before
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone()))
|
||||
.map(|(path, bytes)| (path.replace(slash(&from).trim_end_matches(".jpg"), slash(&to).trim_end_matches(".jpg")), bytes.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
after.sort();
|
||||
assert_eq!(fs.listing(), after);
|
||||
@@ -343,7 +354,7 @@ fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() {
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()];
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() });
|
||||
assert_eq!(file_source(&s, 1), "/p/Trip.jpg");
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
@@ -513,10 +524,11 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
|
||||
);
|
||||
s.merge_undo(3, "Rename Three Times");
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
|
||||
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]);
|
||||
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
|
||||
assert_eq!(moves, vec![("/p/d.jpg".to_string(), "/p/b.jpg".to_string()), ("/p/c.jpg".to_string(), "/p/a.jpg".to_string())]);
|
||||
// The second photo move sticks; the preceding d -> b move must roll back to d.
|
||||
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()];
|
||||
let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string();
|
||||
let error = slash(&file_history_step(&mut s, &fs, false).unwrap_err().to_string());
|
||||
assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}");
|
||||
assert!(error.contains("/p/c.jpg could not be moved back"), "{error}");
|
||||
let files = fs.listing();
|
||||
@@ -529,9 +541,8 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
|
||||
]
|
||||
);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
|
||||
let photo2 = s.catalog.photo(PhotoId(2)).unwrap();
|
||||
assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg");
|
||||
let Source::File { path } = &photo2.source else { panic!("file fixture") };
|
||||
assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
|
||||
assert_eq!(file_source(&s, 1), "/p/a.jpg");
|
||||
let path = file_source(&s, 2);
|
||||
assert_eq!(path, "/p/d.jpg", "photo 2 rolled back physically and must still identify d.jpg");
|
||||
assert_eq!(files.iter().find(|(file, _)| *file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
|
||||
}
|
||||
@@ -40,7 +40,8 @@ impl Scratch {
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
// the host's separator throughout, as the code under test spells joined paths
|
||||
self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a
|
||||
//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn exposure(s: &Session) -> f64 {
|
||||
s.develop_of(s.active().unwrap()).unwrap().light.exposure
|
||||
}
|
||||
|
||||
fn list(s: &mut Session) -> (usize, Option<usize>) {
|
||||
let h = s.execute("history.list", &json!({})).unwrap();
|
||||
(h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize))
|
||||
}
|
||||
|
||||
/// A demo session with three exposure edits (1, 2, 3) as its latest History steps.
|
||||
fn three_edits() -> (Session, usize) {
|
||||
let mut s = Session::with_demo();
|
||||
for v in [1.0, 2.0, 3.0] {
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
|
||||
}
|
||||
let (n, cursor) = list(&mut s);
|
||||
assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step");
|
||||
(s, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_back_adds_no_step_and_forward_returns() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
|
||||
assert_eq!(exposure(&s), 1.0);
|
||||
assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept");
|
||||
s.execute("history.next", &json!({})).unwrap();
|
||||
assert_eq!(exposure(&s), 2.0);
|
||||
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
assert_eq!(list(&mut s), (n, Some(n - 1)));
|
||||
assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step");
|
||||
s.execute("history.previous", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2))));
|
||||
assert!(s.execute("history.restore", &json!({"index": n})).is_err());
|
||||
assert!(s.execute("history.restore", &json!({"index": -1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_edit_behind_the_end_drops_the_later_steps() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
|
||||
assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended");
|
||||
assert_eq!(exposure(&s), -1.0);
|
||||
// undo brings the dropped steps and the cursor back
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_move_the_cursor_with_the_settings() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_history_resets_the_cursor() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
|
||||
s.execute("history.clear", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0))));
|
||||
assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() {
|
||||
let mut s = Session::with_demo();
|
||||
let snap = s.catalog.to_snapshot();
|
||||
let _ = s.drain_log();
|
||||
for v in [1.0, 2.0] {
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
|
||||
}
|
||||
let (n, _) = list(&mut s);
|
||||
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
|
||||
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
|
||||
let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap();
|
||||
c.replay(&log).unwrap();
|
||||
assert_eq!(c.to_snapshot(), s.catalog.to_snapshot());
|
||||
let id = s.active().unwrap();
|
||||
assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2));
|
||||
// a photo written before the cursor existed has no `history_cursor` key: at the latest step
|
||||
let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap();
|
||||
v.as_object_mut().unwrap().remove("history_cursor");
|
||||
let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap();
|
||||
assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1)));
|
||||
}
|
||||
@@ -21,18 +21,41 @@ fn write_png(path: &Path, seed: u8) {
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
|
||||
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value, key: &str) -> usize {
|
||||
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();
|
||||
@@ -309,6 +332,27 @@ fn recently_added_covers_recent_imports_newest_first() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_added_keeps_an_import_batch_in_file_order() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut s = Session::new();
|
||||
// one import batch: one `imported` string, ids running opposite to the names
|
||||
let mut by_name = Vec::new();
|
||||
for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() {
|
||||
let id = PhotoId(5000 - n as u64);
|
||||
let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
by_name.push(name.to_string());
|
||||
}
|
||||
s.clock = Box::new(|| "2026-10-01T09:00:00".to_string());
|
||||
for ascending in [true, false] {
|
||||
s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap();
|
||||
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
|
||||
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
|
||||
assert_eq!(names, by_name, "ascending: {ascending}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
|
||||
/// import order), and a metadata preset on every photo.
|
||||
#[test]
|
||||
@@ -560,6 +604,14 @@ fn cube_luts_become_profiles() {
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
s.execute("profile.favorite", &json!({"id": id})).unwrap();
|
||||
assert!(s.profile_favorites.contains(&id));
|
||||
// `profiles.list` lists it next to the built-in profiles (#618)
|
||||
let listed = |s: &mut Session| {
|
||||
let list = s.execute("profiles.list", &json!({})).unwrap();
|
||||
let list = list.as_array().unwrap();
|
||||
assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed");
|
||||
list.iter().find(|p| p["id"] == id.as_str()).cloned()
|
||||
};
|
||||
assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true})));
|
||||
// the library keeps it
|
||||
drop(s);
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
@@ -569,8 +621,10 @@ fn cube_luts_become_profiles() {
|
||||
assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen");
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
|
||||
assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen");
|
||||
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_none());
|
||||
assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -855,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]
|
||||
@@ -1502,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
|
||||
|
||||
@@ -22,6 +22,9 @@ fn write_png(path: &Path, seed: u8) {
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
|
||||
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
fn xmp(rating: u8) -> String {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -155,13 +155,22 @@ fn odd_folders_never_fail_the_check() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
|
||||
std::fs::create_dir_all(&bad).unwrap();
|
||||
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
|
||||
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
|
||||
let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe"));
|
||||
// APFS (macOS) refuses names that are not valid UTF-8 (EILSEQ): there the non-UTF-8 cases are skipped and
|
||||
// the symlink cases below still run on a plain-named catalog folder
|
||||
let probe = root.join(std::ffi::OsStr::from_bytes(b"\xff"));
|
||||
let utf8_only = std::fs::create_dir(&probe).is_err();
|
||||
let _ = std::fs::remove_dir(&probe);
|
||||
if !utf8_only {
|
||||
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
|
||||
std::fs::create_dir_all(&bad).unwrap();
|
||||
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
|
||||
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
|
||||
}
|
||||
let lr = root.join(if utf8_only { std::ffi::OsStr::new("lr") } else { std::ffi::OsStr::from_bytes(b"\xff\xfe") });
|
||||
std::fs::create_dir_all(&lr).unwrap();
|
||||
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
|
||||
if !utf8_only {
|
||||
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
|
||||
}
|
||||
std::fs::write(lr.join("b.lrcat"), b"x").unwrap();
|
||||
assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped");
|
||||
// a symlink to a catalog folder is one too; a dangling one is not
|
||||
|
||||
@@ -67,6 +67,31 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
|
||||
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
|
||||
#[test]
|
||||
fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() {
|
||||
let dir = temp_dir("hdr-colour");
|
||||
let neutral = [0.5, 1.0, 0.7];
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() {
|
||||
// the same pixels with a white balance but no colour matrices
|
||||
let mut raw = lightcraft_raw::decode(&b).unwrap();
|
||||
raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() };
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let ids = import(&mut s, paths);
|
||||
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
|
||||
let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color);
|
||||
let got = merged.color.as_shot_neutral.expect("the sources' white balance");
|
||||
assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panorama_command_with_auto_crop() {
|
||||
let dir = temp_dir("pano");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,23 @@ use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends.
|
||||
#[test]
|
||||
fn up_and_down_move_by_rows_and_stop_at_the_ends() {
|
||||
let mut s = Session::with_demo();
|
||||
let vis = s.visible_cloned();
|
||||
assert!(vis.len() >= 6);
|
||||
s.selection = crate::Selection::single(vis[0]);
|
||||
s.execute("library.down", &json!({"rows": 4})).unwrap();
|
||||
assert_eq!(s.selection.active, Some(vis[4]));
|
||||
s.execute("library.up", &json!({})).unwrap();
|
||||
assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)");
|
||||
s.execute("library.up", &json!({"rows": 99})).unwrap();
|
||||
assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo");
|
||||
s.execute("library.down", &json!({"rows": u64::MAX})).unwrap();
|
||||
assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow");
|
||||
}
|
||||
|
||||
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
|
||||
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,6 +7,26 @@ use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
|
||||
/// Whether `dir` is on a case-insensitive volume (the default on macOS and Windows): its path in upper case still resolves.
|
||||
pub(crate) fn case_insensitive(dir: &Path) -> bool {
|
||||
Path::new(&dir.to_string_lossy().to_uppercase()).exists()
|
||||
}
|
||||
|
||||
/// The same path, or the same file under another spelling (`a.XMP` and `a.xmp` on a case-insensitive volume). Fault
|
||||
/// injectors match this way, so an injected failure fires whichever spelling the code under test uses for a file; on a
|
||||
/// case-sensitive volume only identical paths match.
|
||||
pub(crate) fn same_path_or_file(a: &Path, b: &Path) -> bool {
|
||||
a == b || RealFs.same_file(a, b)
|
||||
}
|
||||
|
||||
/// The same path, or on a case-insensitive volume a path that differs only in ASCII case: the code under test may spell
|
||||
/// a sidecar `.XMP` for a file stored as `.xmp` there, while on a case-sensitive volume it offers only the spelling that
|
||||
/// exists.
|
||||
pub(crate) fn same_path_for_volume(a: &Path, b: &Path) -> bool {
|
||||
a == b || (a.parent().is_some_and(case_insensitive) && a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy()))
|
||||
}
|
||||
|
||||
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id()));
|
||||
@@ -175,6 +195,71 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A sidecar with only `crs:` develop settings, as LRTimelapse writes them (issue #934).
|
||||
fn crs_exposure_sidecar(exposure: &str) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="15.0" crs:Exposure2012="{exposure}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Issue #934: with auto-write on, Read Metadata from File wrote what it had just read straight back
|
||||
/// into the sidecar, adding our `lc:settings`. Reading leaves the file byte for byte; an edit made
|
||||
/// after it is still written.
|
||||
#[test]
|
||||
fn reading_metadata_leaves_the_sidecar_alone_with_auto_write_on() {
|
||||
let src = temp_dir("read-no-autowrite");
|
||||
write_png(&src.join("frame.png"), 6);
|
||||
let sidecar = src.join("frame.xmp");
|
||||
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
|
||||
// something for the read to change back (auto-write is still off, so the file stays as it is)
|
||||
s.execute("develop.reset", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let before = std::fs::read(&sidecar).unwrap();
|
||||
let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5, "the sidecar was read");
|
||||
let now = std::fs::read(&sidecar).unwrap();
|
||||
assert!(now == before, "reading leaves the sidecar byte for byte, but it is now:\n{}", String::from_utf8_lossy(&now));
|
||||
// a develop edit afterwards is still written (only the read is exempt)
|
||||
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.9})).unwrap();
|
||||
let after = std::fs::read_to_string(&sidecar).unwrap();
|
||||
match crate::sidecar::parse_sidecar(&after, crate::crs::Target::Rendered).unwrap().develop {
|
||||
Some(crate::sidecar::DevelopPatch::Full(d)) => assert_eq!(d.light.exposure, 0.9),
|
||||
other => panic!("the edit wasn't auto-written: {other:?}\n{after}"),
|
||||
}
|
||||
assert!(after.contains(r#"crs:Exposure2012="0.5""#), "the other app's settings are kept: {after}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Issue #934, the round trip the write-back broke: after a read with auto-write on, another app
|
||||
/// changes `crs:Exposure2012` from 0.5 to 1.2 (keeping the rest of the file), and the next read
|
||||
/// takes 1.2.
|
||||
#[test]
|
||||
fn a_crs_change_after_a_read_is_taken_with_auto_write_on() {
|
||||
let src = temp_dir("read-then-crs-change");
|
||||
write_png(&src.join("frame.png"), 7);
|
||||
let sidecar = src.join("frame.xmp");
|
||||
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
|
||||
// the other app edits the file as it is now
|
||||
let text = std::fs::read_to_string(&sidecar).unwrap();
|
||||
let changed = text.replace(r#"crs:Exposure2012="0.5""#, r#"crs:Exposure2012="1.2""#);
|
||||
assert_ne!(changed, text);
|
||||
std::fs::write(&sidecar, changed).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 1.2, "the second read takes the other app's exposure");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar
|
||||
/// and our own starting look stands in for them: importing one gives no camera-profile warning.
|
||||
#[test]
|
||||
@@ -200,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");
|
||||
@@ -211,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>"#;
|
||||
@@ -263,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");
|
||||
|
||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user