Compare commits
83
Commits
No files matched your search
@@ -31,6 +31,7 @@ and often so a crash loses minutes, not hours.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
|
||||
@@ -31,6 +31,7 @@ and often so a crash loses minutes, not hours.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
|
||||
Generated
+25
-21
@@ -2073,7 +2073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2090,7 +2090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -2103,7 +2103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -2117,7 +2117,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
@@ -2142,7 +2142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -2151,7 +2151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -2162,7 +2162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"libc",
|
||||
@@ -2180,6 +2180,7 @@ dependencies = [
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2188,7 +2189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2196,7 +2197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -2212,7 +2213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -2223,7 +2224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -2239,7 +2240,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -2251,7 +2252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2267,7 +2268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -2279,7 +2280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -2291,8 +2292,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
@@ -2303,11 +2305,13 @@ dependencies = [
|
||||
"rayon",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2318,7 +2322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2327,7 +2331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
@@ -2347,7 +2351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5115,7 +5119,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,12 +1,16 @@
|
||||
LightCraft
|
||||
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
This product bundles third-party material under the following licences:
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
|
||||
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Third-party material
|
||||
|
||||
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
|
||||
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
|
||||
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
|
||||
@@ -396,12 +396,21 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft is licensed under **MIT OR Apache-2.0**.
|
||||
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
Inter (SIL OFL 1.1). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
|
||||
+24
-21
@@ -26,53 +26,56 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; accessibility, localisation ⬜) |
|
||||
|
||||
## Parity estimate (2026-10-02)
|
||||
## Parity estimate (2026-10-02, evening update)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows |
|
||||
|---|---:|---:|
|
||||
| P0 (core) | **98.5%** | 198 |
|
||||
| P1 (important parity) | **79.9%** | 144 |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **11.7%** | 158 |
|
||||
| **All in-scope rows** | **65.7%** | 500 |
|
||||
| Scope | Weighted completion | Rows | Morning |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.7%** | 198 | 98.5% |
|
||||
| P1 (important parity) | **94.4%** | 144 | 79.9% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **29.4%** | 158 | 11.7% |
|
||||
| **All in-scope rows** | **75.6%** | 500 | 65.7% |
|
||||
|
||||
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
|
||||
|
||||
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
|
||||
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
|
||||
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
|
||||
UI/feature work in ≈ 5 h on 2026-10-01, and four to six parallel agents built M0–M13 in ≈ 25 active hours):
|
||||
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
|
||||
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
|
||||
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
|
||||
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (version hover previews, filter presets, quick develop, missing-file relink, metadata presets, keyword sets…) | ≈ 30 | 20–35 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed NEF / ORF / RAF, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
|
||||
| Classic modules: Map, Book, Slideshow, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
|
||||
| Smaller P2 items: watched folders, Enhance dialog, external-editor round trip, export to Photos, help / what's new, accessibility, localisation | ≈ 25 | 20–35 | low |
|
||||
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
|
||||
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
|
||||
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
|
||||
| **Total remaining** | | **≈ 295–540** | |
|
||||
| **Total remaining** | | **≈ 270–505** | |
|
||||
|
||||
Spent so far ≈ 90–130 agent-hours, so **by effort the project is ≈ 20–30% of the way to complete Lightroom + Classic
|
||||
parity**, and ≈ 40–55% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 135–235 h).
|
||||
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
|
||||
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
|
||||
most of it the raw codecs, lens data, video and HDR).
|
||||
|
||||
**Wall clock:** ≈ 300–540 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
|
||||
CI under load cost the rest) **≈ 75–130 h of continuous work**. The AI package and the raw codecs carry most of the
|
||||
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
|
||||
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
|
||||
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
|
||||
|
||||
## Totals
|
||||
|
||||
Remaining from 2026-10-02 (see *Parity estimate* above for the breakdown):
|
||||
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
|
||||
|
||||
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|
||||
|---|---:|---:|
|
||||
| Cloud-Lightroom parity without AI or Classic modules | ≈ 135–235 h | ≈ 35–60 h |
|
||||
| Full parity incl. AI, video and the Classic modules | ≈ 295–540 h | ≈ 75–130 h |
|
||||
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
|
||||
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
|
||||
|
||||
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
|
||||
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
|
||||
@@ -91,7 +94,7 @@ The milestone estimates in the table above were made before work started and are
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed, RAF uncompressed (Bayer and
|
||||
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
|
||||
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
|
||||
it can't decode yet.
|
||||
|
||||
@@ -154,6 +154,8 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
write: Some(Box::new(download)),
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
@@ -161,6 +163,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
reveal: None,
|
||||
open_with: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
|
||||
|
||||
@@ -56,6 +56,7 @@ impl WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.width.max(img.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
level: job.level,
|
||||
@@ -91,6 +92,7 @@ impl WireJob {
|
||||
// workers render previews (exports run in-process)
|
||||
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
proof: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `lightcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
|
||||
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
|
||||
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
@@ -95,6 +100,28 @@ fn save_prefs(app: &LightcraftApp) {
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
open_with: Some(Box::new(|path: &str, app: &str| {
|
||||
// spawned, never waited for: the editor runs alongside
|
||||
let app = app.trim();
|
||||
let mut c = if cfg!(target_os = "macos") {
|
||||
let mut c = std::process::Command::new("open");
|
||||
if !app.is_empty() {
|
||||
c.args(["-a", app]);
|
||||
}
|
||||
c
|
||||
} else if app.is_empty() {
|
||||
if cfg!(target_os = "windows") {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/C", "start", ""]);
|
||||
c
|
||||
} else {
|
||||
std::process::Command::new("xdg-open")
|
||||
}
|
||||
} else {
|
||||
std::process::Command::new(app)
|
||||
};
|
||||
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
})),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if !url.starts_with("https://") {
|
||||
return Err("only https links are opened".into());
|
||||
@@ -139,7 +166,7 @@ fn services() -> Services {
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets", &["lcpreset", "xmp"])
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -154,6 +181,24 @@ fn services() -> Services {
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -122,6 +122,8 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
|
||||
"'" => Code::Quote,
|
||||
"," => Code::Comma,
|
||||
"Delete" => Code::Backspace,
|
||||
// with modifiers only: a bare Enter belongs to text fields and tools
|
||||
"Enter" if !mods.is_empty() => Code::Enter,
|
||||
"F1" => Code::F1,
|
||||
"F2" => Code::F2,
|
||||
"F3" => Code::F3,
|
||||
@@ -243,6 +245,10 @@ impl NativeMenu {
|
||||
.collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == "File" {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
|
||||
}
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
|
||||
@@ -17,6 +17,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
@@ -51,38 +51,60 @@ 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> {
|
||||
fn insert(nodes: &mut Vec<KeywordNode>, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) {
|
||||
let Some((first, rest)) = parts.split_first() else { return };
|
||||
let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") };
|
||||
let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() });
|
||||
nodes.len() - 1
|
||||
}
|
||||
};
|
||||
if seen.insert((path.to_lowercase(), photo)) {
|
||||
nodes[i].count += 1;
|
||||
}
|
||||
let p = nodes[i].path.clone();
|
||||
insert(&mut nodes[i].children, rest, &p, photo, seen);
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// every keyword level (`travel`, `travel|italy`…) by its 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)
|
||||
struct Level {
|
||||
name: String,
|
||||
path: String,
|
||||
count: usize,
|
||||
parent: Option<String>,
|
||||
}
|
||||
fn sort(nodes: &mut [KeywordNode]) {
|
||||
nodes.sort_by_key(|n| n.name.to_lowercase());
|
||||
for n in nodes {
|
||||
sort(&mut n.children);
|
||||
}
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut this_photo: HashSet<String> = HashSet::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
this_photo.clear();
|
||||
for k in &p.meta.keywords {
|
||||
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
insert(&mut roots, &parts, "", p.id.0, &mut seen);
|
||||
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);
|
||||
}
|
||||
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 this_photo.insert(lower.clone()) {
|
||||
l.count += 1;
|
||||
}
|
||||
parent = Some(lower.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
sort(&mut roots);
|
||||
roots
|
||||
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());
|
||||
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 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
build(None, &levels, &mut kids)
|
||||
}
|
||||
|
||||
/// `SetMeta` ops for every photo whose keywords `f` changes.
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
pub mod stacks;
|
||||
pub mod store;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::{FsStore, MemStore, Store};
|
||||
|
||||
@@ -147,6 +149,11 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
captured: Option<String>,
|
||||
},
|
||||
/// Assisted culling scores.
|
||||
SetAnalysis {
|
||||
id: PhotoId,
|
||||
analysis: Option<crate::Analysis>,
|
||||
},
|
||||
/// Rename a photo: its file name and the source it points to. Applying the op never touches
|
||||
/// the disk — the engine moves the file before it commits (and on undo/redo).
|
||||
SetFile {
|
||||
@@ -160,6 +167,22 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
file_name: String,
|
||||
source: Source,
|
||||
/// The file's format when it changes too (Convert to DNG).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
format: Option<String>,
|
||||
},
|
||||
/// What a photo's file is now (after it changed on disk: Reload).
|
||||
SetContent {
|
||||
id: PhotoId,
|
||||
width: u32,
|
||||
height: u32,
|
||||
file_size: u64,
|
||||
#[serde(default)]
|
||||
content_hash: Option<String>,
|
||||
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
|
||||
/// `None` = its raw data decodes (or it isn't a raw).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
preview_only: Option<String>,
|
||||
},
|
||||
/// The name shown for a colour label (`None` = its colour's name).
|
||||
SetLabelName {
|
||||
@@ -233,6 +256,11 @@ impl Catalog {
|
||||
pub fn albums(&self) -> impl Iterator<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
/// The Quick Collection, once something was added to it.
|
||||
pub fn quick_collection(&self) -> Option<AlbumId> {
|
||||
self.albums.values().find(|a| a.quick).map(|a| a.id)
|
||||
}
|
||||
|
||||
/// Albums (regular and smart) that contain the photo.
|
||||
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
|
||||
let Some(p) = self.photos.get(&id) else { return Vec::new() };
|
||||
@@ -288,6 +316,11 @@ impl Catalog {
|
||||
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
|
||||
self.label_names.get(&l).map(String::as_str)
|
||||
}
|
||||
/// The label a name stands for: a custom name first, then a colour's own name (any case).
|
||||
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
|
||||
let name = name.trim();
|
||||
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
|
||||
}
|
||||
|
||||
// ---- writes
|
||||
|
||||
@@ -466,6 +499,10 @@ impl Catalog {
|
||||
s.collapsed = collapsed;
|
||||
old
|
||||
}
|
||||
Op::SetAnalysis { id, analysis } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
|
||||
}
|
||||
Op::SetCaptured { id, captured } => {
|
||||
if let Some(c) = &captured
|
||||
&& stacks::iso_seconds(c).is_none()
|
||||
@@ -475,10 +512,22 @@ impl Catalog {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
|
||||
}
|
||||
Op::Relink { id, file_name, source } => {
|
||||
Op::Relink { id, file_name, source, format } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
let old_name = std::mem::replace(&mut p.file_name, file_name);
|
||||
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
|
||||
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
|
||||
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
|
||||
}
|
||||
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetContent {
|
||||
id,
|
||||
width: std::mem::replace(&mut p.width, width),
|
||||
height: std::mem::replace(&mut p.height, height),
|
||||
file_size: std::mem::replace(&mut p.file_size, file_size),
|
||||
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
|
||||
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
|
||||
}
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
if file_name.trim().is_empty() {
|
||||
|
||||
@@ -197,6 +197,28 @@ pub struct Photo {
|
||||
/// unedited, and Reset returns to them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub import_look: Option<Arc<DevelopSettings>>,
|
||||
/// Assisted culling scores (`None` until analysed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub analysis: Option<Analysis>,
|
||||
/// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the
|
||||
/// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and
|
||||
/// edited from the camera's embedded JPEG preview — a rendered image with the camera's
|
||||
/// picture style baked in — so it is treated as a rendered (non-raw) source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
}
|
||||
|
||||
/// What assisted culling measured on a photo.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Analysis {
|
||||
/// Focus: 0 (blurred) .. 100 (crisp), from the detail in the photo's sharpest area.
|
||||
pub sharpness: f32,
|
||||
/// Exposure: share of clipped pixels, 0..1 (shadows + highlights).
|
||||
pub clipped: f32,
|
||||
/// Similar-shot group (burst), when the photo has neighbours that look alike.
|
||||
pub group: Option<u32>,
|
||||
/// The best photo of its group.
|
||||
pub best: bool,
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
@@ -229,8 +251,15 @@ impl Photo {
|
||||
copy_of: None,
|
||||
copy_name: None,
|
||||
import_look: None,
|
||||
analysis: None,
|
||||
preview_only: None,
|
||||
}
|
||||
}
|
||||
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
|
||||
/// its embedded preview ([`Photo::preview_only`]).
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
@@ -283,12 +312,15 @@ pub struct Album {
|
||||
/// live; `photos` stays empty.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub smart: Option<Box<crate::Filter>>,
|
||||
/// The Quick Collection: a regular album that B adds to (one per library).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub quick: bool,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// A regular (manual) album.
|
||||
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
|
||||
}
|
||||
pub fn is_smart(&self) -> bool {
|
||||
self.smart.is_some()
|
||||
|
||||
@@ -24,6 +24,12 @@ pub struct Filter {
|
||||
pub rating_op: RatingOp,
|
||||
pub flag: Option<Flag>,
|
||||
pub label: Option<ColorLabel>,
|
||||
/// Only these photos (Find Similar results…); empty = no constraint.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub only: Vec<PhotoId>,
|
||||
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub labels: Vec<ColorLabel>,
|
||||
pub kind: Option<MediaKind>,
|
||||
pub edited: Option<bool>,
|
||||
pub album: Option<AlbumId>,
|
||||
@@ -40,11 +46,18 @@ pub struct Filter {
|
||||
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
/// Import date prefix (Recently Added).
|
||||
/// Import date prefix.
|
||||
pub imported: Option<String>,
|
||||
/// Imported at or after this time (ISO; Recently Added).
|
||||
pub imported_from: Option<String>,
|
||||
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
|
||||
pub merged: Option<String>,
|
||||
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
|
||||
pub folder: Option<String>,
|
||||
pub subfolders: bool,
|
||||
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rule_set: Option<crate::RuleSet>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -130,6 +143,9 @@ impl Filter {
|
||||
if let Some(l) = self.label {
|
||||
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
|
||||
}
|
||||
if !self.labels.is_empty() {
|
||||
v.push(format!("label {}", self.labels.iter().map(|l| format!("{l:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
|
||||
}
|
||||
if let Some(k) = self.kind {
|
||||
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
|
||||
}
|
||||
@@ -152,6 +168,9 @@ impl Filter {
|
||||
if !self.text.trim().is_empty() {
|
||||
v.push(format!("“{}”", self.text.trim()));
|
||||
}
|
||||
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
v.push(rs.describe());
|
||||
}
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
@@ -186,6 +205,23 @@ impl Filter {
|
||||
if self.edited.is_some_and(|e| e != p.is_edited()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(rs) = &self.rule_set
|
||||
&& !rs.matches(p, cat)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
|
||||
return false;
|
||||
}
|
||||
if !self.only.is_empty() && !self.only.contains(&p.id) {
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
match merged_kind(&p.file_name) {
|
||||
Some(k) if want == "any" || want == k => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if let Some(a) = self.album
|
||||
&& !cat.album_contains(a, p)
|
||||
{
|
||||
@@ -216,6 +252,11 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = &self.imported_from
|
||||
&& p.imported.as_str() < d.as_str()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(k) = &self.keyword
|
||||
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
|
||||
{
|
||||
@@ -304,3 +345,20 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
|
||||
/// `hdr`, `panorama` or `hdrPanorama`.
|
||||
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
|
||||
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
|
||||
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
|
||||
if stem.ends_with("-hdr-pano") {
|
||||
Some("hdrPanorama")
|
||||
} else if stem.ends_with("-hdr") {
|
||||
Some("hdr")
|
||||
} else if stem.ends_with("-pano") {
|
||||
Some("panorama")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
|
||||
//!
|
||||
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
|
||||
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
|
||||
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Catalog, Photo};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Match {
|
||||
#[default]
|
||||
All,
|
||||
Any,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuleSet {
|
||||
#[serde(default, rename = "match")]
|
||||
pub mode: Match,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Rule {
|
||||
/// A nested group with its own match mode.
|
||||
Group { group: RuleSet },
|
||||
Field {
|
||||
field: String,
|
||||
op: String,
|
||||
#[serde(default)]
|
||||
value: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// How a field's value is compared.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Text,
|
||||
/// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
|
||||
Keywords,
|
||||
Number,
|
||||
Date,
|
||||
/// One of a fixed set (`choices`).
|
||||
Choice(&'static [&'static str]),
|
||||
Bool,
|
||||
}
|
||||
|
||||
/// The fields rules can test: (id, label, kind).
|
||||
pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("rating", "Rating", Kind::Number),
|
||||
("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])),
|
||||
("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])),
|
||||
("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
|
||||
("edited", "Has Edits", Kind::Bool),
|
||||
("keywords", "Keywords", Kind::Keywords),
|
||||
("text", "Any Searchable Text", Kind::Text),
|
||||
("fileName", "Filename", Kind::Text),
|
||||
("format", "File Format", Kind::Text),
|
||||
("title", "Title", Kind::Text),
|
||||
("caption", "Caption", Kind::Text),
|
||||
("camera", "Camera", Kind::Text),
|
||||
("lens", "Lens", Kind::Text),
|
||||
("location", "Location", Kind::Text),
|
||||
("creator", "Creator", Kind::Text),
|
||||
("copyright", "Copyright", Kind::Text),
|
||||
("captureDate", "Capture Date", Kind::Date),
|
||||
("importDate", "Import Date", Kind::Date),
|
||||
("editDate", "Edit Date", Kind::Date),
|
||||
("iso", "ISO Speed", Kind::Number),
|
||||
("aperture", "Aperture", Kind::Number),
|
||||
("focalLength", "Focal Length", Kind::Number),
|
||||
("megapixels", "Megapixels", Kind::Number),
|
||||
("hasGps", "Has GPS", Kind::Bool),
|
||||
("virtualCopy", "Virtual Copy", Kind::Bool),
|
||||
("album", "Album", Kind::Number),
|
||||
("sharpness", "Focus (assisted culling)", Kind::Number),
|
||||
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
|
||||
];
|
||||
|
||||
/// The operators for a field kind: (id, label).
|
||||
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
Kind::Text => &[
|
||||
("contains", "contains"),
|
||||
("notContains", "doesn't contain"),
|
||||
("is", "is"),
|
||||
("isNot", "isn't"),
|
||||
("startsWith", "starts with"),
|
||||
("endsWith", "ends with"),
|
||||
("isEmpty", "is empty"),
|
||||
("isNotEmpty", "isn't empty"),
|
||||
],
|
||||
Kind::Keywords => &[
|
||||
("contains", "contains"),
|
||||
("notContains", "doesn't contain"),
|
||||
("is", "is"),
|
||||
("startsWith", "starts with"),
|
||||
("isEmpty", "are empty"),
|
||||
("isNotEmpty", "aren't empty"),
|
||||
],
|
||||
Kind::Number => {
|
||||
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
|
||||
}
|
||||
Kind::Date => &[
|
||||
("is", "is"),
|
||||
("after", "is after"),
|
||||
("before", "is before"),
|
||||
("between", "is between"),
|
||||
("inLast", "is in the last"),
|
||||
("notInLast", "isn't in the last"),
|
||||
("isEmpty", "is unknown"),
|
||||
],
|
||||
Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
|
||||
Kind::Bool => &[("is", "is")],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn field_kind(field: &str) -> Option<Kind> {
|
||||
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
|
||||
pub fn set_now(iso: Option<String>) {
|
||||
NOW.with(|n| *n.borrow_mut() = iso);
|
||||
}
|
||||
|
||||
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
|
||||
pub fn now() -> String {
|
||||
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
|
||||
return n;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||||
crate::dates::civil(secs)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
"2026-01-01T00:00:00".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn lower(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.trim().to_lowercase(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string().to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_op(op: &str, have: &str, want: &str) -> bool {
|
||||
let have = have.trim().to_lowercase();
|
||||
match op {
|
||||
"contains" => want.split_whitespace().all(|w| have.contains(w)),
|
||||
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
|
||||
"is" => have == want,
|
||||
"isNot" => have != want,
|
||||
"startsWith" => have.starts_with(want),
|
||||
"endsWith" => have.ends_with(want),
|
||||
"isEmpty" => have.is_empty(),
|
||||
"isNotEmpty" => !have.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
|
||||
if op == "between" {
|
||||
let (a, b) = match value {
|
||||
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
|
||||
_ => (None, None),
|
||||
};
|
||||
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
|
||||
}
|
||||
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
|
||||
match op {
|
||||
"is" => (h - w).abs() < 1e-6,
|
||||
"isNot" => (h - w).abs() >= 1e-6,
|
||||
"gte" => h >= w - 1e-9,
|
||||
"lte" => h <= w + 1e-9,
|
||||
"gt" => h > w,
|
||||
"lt" => h < w,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
|
||||
fn last_secs(value: &Value) -> Option<i64> {
|
||||
let (n, unit) = match value {
|
||||
Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")),
|
||||
v => (number(v)?, "days"),
|
||||
};
|
||||
let day = 86_400.0;
|
||||
let per = match unit {
|
||||
"hours" => 3600.0,
|
||||
"weeks" => 7.0 * day,
|
||||
"months" => 30.4375 * day,
|
||||
"years" => 365.25 * day,
|
||||
_ => day,
|
||||
};
|
||||
Some((n * per) as i64)
|
||||
}
|
||||
|
||||
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
|
||||
if op == "isEmpty" {
|
||||
return have.is_none_or(str::is_empty);
|
||||
}
|
||||
let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" };
|
||||
let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string();
|
||||
match op {
|
||||
// a prefix: 2026, 2026-04, 2026-04-12
|
||||
"is" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h.starts_with(&w)
|
||||
}
|
||||
"after" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
|
||||
}
|
||||
"before" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h < w.as_str()
|
||||
}
|
||||
"between" => match value {
|
||||
Value::Array(v) if v.len() == 2 => {
|
||||
let (a, b) = (s(&v[0]), s(&v[1]));
|
||||
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
"inLast" | "notInLast" => {
|
||||
let Some(secs) = last_secs(value) else { return false };
|
||||
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
|
||||
(h >= from.as_str()) == (op == "inLast")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
let (field, op, value) = match self {
|
||||
Rule::Group { group } => return group.matches(p, cat),
|
||||
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
|
||||
};
|
||||
let m = &p.meta;
|
||||
let want = lower(value);
|
||||
let text = |have: &str| text_op(op, have, &want);
|
||||
match field {
|
||||
"rating" => num_op(op, Some(p.rating as f64), value),
|
||||
"flag" => {
|
||||
let f = format!("{:?}", p.flag).to_lowercase();
|
||||
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
|
||||
}
|
||||
"label" => {
|
||||
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
|
||||
// custom label names count too
|
||||
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
|
||||
(l == want || named) == (op == "is")
|
||||
}
|
||||
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
|
||||
"edited" => p.is_edited() == value.as_bool().unwrap_or(true),
|
||||
"hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true),
|
||||
"virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true),
|
||||
"keywords" => match op {
|
||||
"isEmpty" => m.keywords.is_empty(),
|
||||
"isNotEmpty" => !m.keywords.is_empty(),
|
||||
"notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)),
|
||||
_ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))),
|
||||
},
|
||||
"text" => {
|
||||
let all = [
|
||||
p.file_name.as_str(),
|
||||
&m.title,
|
||||
&m.caption,
|
||||
&m.camera,
|
||||
&m.lens,
|
||||
&m.location,
|
||||
&m.city,
|
||||
&m.country,
|
||||
&p.format,
|
||||
&m.keywords.join(" "),
|
||||
]
|
||||
.join(" ");
|
||||
text(&all)
|
||||
}
|
||||
"fileName" => text(&p.file_name),
|
||||
"format" => text(&p.format),
|
||||
"title" => text(&m.title),
|
||||
"caption" => text(&m.caption),
|
||||
"camera" => text(&m.camera),
|
||||
"lens" => text(&m.lens),
|
||||
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
|
||||
"creator" => text(&m.creator),
|
||||
"copyright" => text(&m.copyright),
|
||||
"captureDate" => date_op(op, p.captured.as_deref(), value),
|
||||
"importDate" => date_op(op, Some(&p.imported), value),
|
||||
"editDate" => date_op(op, p.edited.as_deref(), value),
|
||||
"iso" => num_op(op, m.iso.map(|v| v as f64), value),
|
||||
"aperture" => num_op(op, m.aperture.map(|v| v as f64), value),
|
||||
"focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value),
|
||||
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
|
||||
"sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value),
|
||||
"bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true),
|
||||
"album" => {
|
||||
let id = number(value).map(|v| crate::AlbumId(v as u64));
|
||||
id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuleSet {
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if self.rules.is_empty() {
|
||||
return self.mode != Match::Any;
|
||||
}
|
||||
match self.mode {
|
||||
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
|
||||
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
|
||||
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown fields or operators (for command validation), as readable messages.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in &self.rules {
|
||||
match r {
|
||||
Rule::Group { group } => out.extend(group.problems()),
|
||||
Rule::Field { field, op, .. } => match field_kind(field) {
|
||||
None => out.push(format!("unknown field `{field}`")),
|
||||
Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
|
||||
pub fn describe(&self) -> String {
|
||||
let join = match self.mode {
|
||||
Match::All => " and ",
|
||||
Match::Any => " or ",
|
||||
Match::None => " nor ",
|
||||
};
|
||||
let parts: Vec<String> = self
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| match r {
|
||||
Rule::Group { group } => format!("({})", group.describe()),
|
||||
Rule::Field { field, op, value } => {
|
||||
let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase();
|
||||
let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1);
|
||||
let v = match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
Value::Object(o) => format!(
|
||||
"{} {}",
|
||||
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
|
||||
o.get("unit").and_then(Value::as_str).unwrap_or("days")
|
||||
),
|
||||
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
|
||||
other => other.to_string(),
|
||||
};
|
||||
format!("{label} {op} {v}").trim().to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let s = parts.join(join);
|
||||
if self.mode == Match::None { format!("none of: {s}") } else { s }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ColorLabel, Flag, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
fn photo(id: u64) -> Photo {
|
||||
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
|
||||
p.rating = 4;
|
||||
p.flag = Flag::Pick;
|
||||
p.label = Some(ColorLabel::Red);
|
||||
p.captured = Some("2026-08-14T18:30:00".into());
|
||||
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
|
||||
p.meta.camera = "Model X2".into();
|
||||
p.meta.iso = Some(1600);
|
||||
p.meta.aperture = Some(2.8);
|
||||
p
|
||||
}
|
||||
|
||||
fn rs(v: serde_json::Value) -> RuleSet {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_ops_and_groups() {
|
||||
let cat = Catalog::new();
|
||||
let p = photo(1);
|
||||
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
|
||||
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
|
||||
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
|
||||
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
|
||||
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
|
||||
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
|
||||
yes(
|
||||
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
|
||||
);
|
||||
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
|
||||
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
|
||||
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
|
||||
yes(
|
||||
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
|
||||
);
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
|
||||
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
|
||||
// nested: rating ≥ 4 and (label is blue or keywords contain food)
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
|
||||
// in the last N days, relative to now
|
||||
set_now(Some("2026-09-01T00:00:00".into()));
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
|
||||
set_now(None);
|
||||
// an empty rule list: all → everything, any → nothing
|
||||
yes(json!({"rules": []}));
|
||||
no(json!({"match": "any", "rules": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problems_and_description() {
|
||||
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
|
||||
assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
|
||||
let r = rs(
|
||||
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
|
||||
);
|
||||
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
|
||||
// every field has a kind with operators
|
||||
for (f, _, k) in FIELDS {
|
||||
assert!(!ops_for(*k).is_empty(), "{f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,42 @@ impl Catalog {
|
||||
self.group_ops(result, sources, true)
|
||||
}
|
||||
|
||||
/// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the
|
||||
/// top). `None` when it isn't stacked or can't move.
|
||||
pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option<Op> {
|
||||
let st = self.stack_of(photo)?;
|
||||
let i = st.photos.iter().position(|p| *p == photo)?;
|
||||
let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize;
|
||||
if to == i {
|
||||
return None;
|
||||
}
|
||||
let mut photos = st.photos.clone();
|
||||
let p = photos.remove(i);
|
||||
photos.insert(to, p);
|
||||
Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed })
|
||||
}
|
||||
|
||||
/// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a
|
||||
/// new stack (topped by it). A part with a single photo is no longer stacked.
|
||||
pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option<Op> {
|
||||
let st = self.stack_of(photo)?.clone();
|
||||
let i = st.photos.iter().position(|p| *p == photo)?;
|
||||
if i == 0 {
|
||||
return None;
|
||||
}
|
||||
let (head, tail) = st.photos.split_at(i);
|
||||
let mut ops = vec![if head.len() >= 2 {
|
||||
Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed }
|
||||
} else {
|
||||
Op::RemoveStack { id: st.id }
|
||||
}];
|
||||
if tail.len() >= 2 {
|
||||
let id = self.alloc_stack_id();
|
||||
ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } });
|
||||
}
|
||||
Some(Op::Batch { ops })
|
||||
}
|
||||
|
||||
/// Ops that dissolve every stack containing one of `photos`.
|
||||
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
|
||||
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
|
||||
|
||||
@@ -57,11 +57,15 @@ fn albums_and_folders() {
|
||||
let mut c = Catalog::new();
|
||||
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
||||
let f = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } })
|
||||
.unwrap();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
})
|
||||
.unwrap();
|
||||
let al = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } })
|
||||
.unwrap();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(c.albums_of(a), vec![al]);
|
||||
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
|
||||
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
|
||||
@@ -216,3 +220,47 @@ fn folder_paths() {
|
||||
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
|
||||
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_results_are_recognised() {
|
||||
use crate::query::merged_kind;
|
||||
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
|
||||
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
|
||||
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
|
||||
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
|
||||
assert_eq!(merged_kind("hdr.jpg"), None);
|
||||
assert_eq!(merged_kind("panorama.jpg"), None);
|
||||
}
|
||||
|
||||
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
|
||||
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
|
||||
/// with an exact inverse that survives serialisation (the journal).
|
||||
#[test]
|
||||
fn preview_only_survives_serde_and_set_content_undo() {
|
||||
let mut c = Catalog::new();
|
||||
let a = c.alloc_photo_id();
|
||||
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
|
||||
assert!(!p.develops_raw());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
// the catalog round-trips it
|
||||
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
||||
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
||||
// an older catalog (no field) loads as decodable
|
||||
let mut v = serde_json::to_value(&c).unwrap();
|
||||
let photos = v["photos"].as_object_mut().unwrap();
|
||||
let ph = photos.values_mut().next().unwrap();
|
||||
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
|
||||
let old: Catalog = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.photo(a).unwrap().preview_only, None);
|
||||
assert!(old.photo(a).unwrap().develops_raw());
|
||||
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
|
||||
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert!(c.photo(a).unwrap().develops_raw());
|
||||
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
||||
assert_eq!(c.photo(a).unwrap().width, 6000);
|
||||
}
|
||||
@@ -50,7 +50,16 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
5 => {
|
||||
let id = c.alloc_album_id();
|
||||
Op::AddAlbum {
|
||||
album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None },
|
||||
album: Album {
|
||||
id,
|
||||
name: format!("A{b}"),
|
||||
parent: None,
|
||||
folder: b.is_multiple_of(4),
|
||||
photos: vec![],
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
|
||||
|
||||
@@ -105,6 +105,7 @@ mod tests {
|
||||
s.masks.push(Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial {
|
||||
|
||||
@@ -208,8 +208,29 @@ impl Preset {
|
||||
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
|
||||
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
|
||||
}
|
||||
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
|
||||
/// already has are skipped), their effect scaled by the amount.
|
||||
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
|
||||
apply_partial(s, &self.settings, amount)
|
||||
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
|
||||
return apply_partial(s, &self.settings, amount);
|
||||
};
|
||||
let mut rest = self.settings.clone();
|
||||
if let Some(o) = rest.as_object_mut() {
|
||||
o.remove("masks");
|
||||
}
|
||||
let mut out = apply_partial(s, &rest, amount);
|
||||
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
|
||||
for m in masks {
|
||||
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
|
||||
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
|
||||
continue;
|
||||
}
|
||||
next += 1;
|
||||
m.id = next;
|
||||
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
|
||||
out.masks.push(m);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +786,9 @@ pub enum MaskShape {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MaskComponent {
|
||||
/// A name given in the Masking panel (Rename); `None` shows the shape's kind.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub op: MaskOp,
|
||||
#[serde(default)]
|
||||
@@ -802,11 +805,18 @@ pub struct Mask {
|
||||
pub invert: bool,
|
||||
pub components: Vec<MaskComponent>,
|
||||
pub adjust: LocalAdjustments,
|
||||
/// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub refine: f64,
|
||||
}
|
||||
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
impl Default for Mask {
|
||||
fn default() -> Self {
|
||||
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
|
||||
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -55,6 +55,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"folder.rename",
|
||||
"Rename Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
|
||||
.ok_or_else(|| bad("folder.rename", "give a folder name (no slashes)"))?;
|
||||
let to = Path::new(&from).with_file_name(name).to_string_lossy().to_string();
|
||||
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.rename", e))?;
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.move",
|
||||
"Move Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?;
|
||||
let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?;
|
||||
let to = Path::new(into).join(name).to_string_lossy().to_string();
|
||||
let n = move_folder(s, &from, &to).map_err(|e| bad("folder.move", e))?;
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.browse",
|
||||
"Browse Folder",
|
||||
@@ -67,3 +101,59 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
|
||||
]
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
|
||||
/// point the photos back at a folder that no longer exists.
|
||||
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
use std::path::Path;
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
if dst.exists() {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
if dst.starts_with(src) {
|
||||
return Err("a folder can't move into itself".into());
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
let mut n = 0;
|
||||
let ops: Vec<lightcraft_catalog::Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, file_name, np)| {
|
||||
n += 1;
|
||||
lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for op in ops {
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
// a browsed folder at or below it follows
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
|
||||
{
|
||||
b.path = dst.join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Convert to DNG: a raw photo's original is re-encoded as a lossless DNG (its develop settings
|
||||
//! embedded as XMP) next to it, and the photo is relinked to the DNG. The original stays on
|
||||
//! disk; undo relinks the photo to it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{MediaKind, Op, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
|
||||
fn dng_path(path: &str) -> String {
|
||||
let p = Path::new(path);
|
||||
let dir = p.parent().unwrap_or(Path::new(""));
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let mut out = dir.join(format!("{stem}.dng"));
|
||||
let mut n = 2;
|
||||
while out.exists() {
|
||||
out = dir.join(format!("{stem}-{n}.dng"));
|
||||
n += 1;
|
||||
}
|
||||
out.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path.
|
||||
/// An empty `packet` writes no XMP.
|
||||
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match &s.media.file_bytes {
|
||||
Some(r) => r(path)?,
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out = dng_path(path);
|
||||
std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.convertToDng";
|
||||
let ids = s.targets(p);
|
||||
let mut ops = Vec::new();
|
||||
let mut converted = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
let Source::File { path } = &ph.source else {
|
||||
skipped.push(json!([id.0, "not a file"]));
|
||||
continue;
|
||||
};
|
||||
if ph.kind != MediaKind::Raw || ph.format.eq_ignore_ascii_case("DNG") || ph.copy_of.is_some() {
|
||||
skipped.push(json!([
|
||||
id.0,
|
||||
if ph.kind != MediaKind::Raw {
|
||||
"not a raw photo"
|
||||
} else if ph.copy_of.is_some() {
|
||||
"a virtual copy"
|
||||
} else {
|
||||
"already a DNG"
|
||||
}
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
let packet = crate::sidecar::sidecar_packet(&ph, &s.catalog);
|
||||
match write_dng_for(s, path, packet) {
|
||||
Ok(out) => {
|
||||
let file_name = Path::new(&out).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// virtual copies of this photo follow it to the DNG
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.push(Op::Relink {
|
||||
id: c.id,
|
||||
file_name: file_name.clone(),
|
||||
source: Source::File { path: out.clone() },
|
||||
format: Some("DNG".into()),
|
||||
});
|
||||
}
|
||||
ops.push(Op::Relink { id, file_name, source: Source::File { path: out.clone() }, format: Some("DNG".into()) });
|
||||
converted.push(json!({"id": id.0, "path": out, "original": path}));
|
||||
}
|
||||
Err(e) => skipped.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
let n = converted.len();
|
||||
s.commit(&format!("Convert {n} Photo{} to DNG", if n == 1 { "" } else { "s" }), Op::Batch { ops }).map_err(|e| bad(C, e.to_string()))?;
|
||||
}
|
||||
Ok(json!({"converted": converted, "skipped": skipped}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"photo.convertToDng",
|
||||
"Convert to DNG",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — write each raw photo as a lossless DNG next to it (settings embedded) and relink the photo to it; originals are kept → {converted: [{id, path, original}], skipped}",
|
||||
has_selection,
|
||||
convert
|
||||
)]
|
||||
}
|
||||
|
||||
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
|
||||
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
|
||||
/// stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
|
||||
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
|
||||
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
|
||||
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
|
||||
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
|
||||
};
|
||||
let opts = crate::export::ExportOptions {
|
||||
format: crate::export::ExportFormat::Tiff,
|
||||
bit_depth: Some(16),
|
||||
color_space: space,
|
||||
naming: "{name}-Edit".into(),
|
||||
conflict: crate::export::Conflict::Unique,
|
||||
..Default::default()
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
written.push(path.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let to = crate::export::Destination { dir, exact: None };
|
||||
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
||||
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
||||
// stack: the edit on top of the original, expanded so both show
|
||||
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
|
||||
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
|
||||
Ok(json!({"path": out, "id": new, "original": id.0}))
|
||||
}
|
||||
|
||||
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
|
||||
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
|
||||
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
|
||||
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
|
||||
let same = info.content_hash == ph.content_hash
|
||||
&& info.file_size == ph.file_size
|
||||
&& (info.width, info.height) == (ph.width, ph.height)
|
||||
&& info.preview_only == ph.preview_only;
|
||||
(!same).then_some(Op::SetContent {
|
||||
id,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
file_size: info.file_size,
|
||||
content_hash: info.content_hash,
|
||||
preview_only: info.preview_only,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.targets(p);
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
Some(Source::File { path }) => Some((*id, path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
|
||||
let mut ops = Vec::new();
|
||||
let mut reloaded = Vec::new();
|
||||
for ((id, _), info) in paths.into_iter().zip(probed) {
|
||||
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
|
||||
let Some(op) = content_op(id, ph, info.clone()) else { continue };
|
||||
ops.push(op);
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
reloaded.push(id);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
|
||||
/// same settings, metadata, rating, flag, label and albums. → {ids}
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.duplicate";
|
||||
let mut made = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
|
||||
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
|
||||
let src = Path::new(path);
|
||||
let stem = src.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
let dir = src.parent().unwrap_or(Path::new(""));
|
||||
let mut dst = dir.join(format!("{stem}-copy{ext}"));
|
||||
let mut n = 2;
|
||||
while dst.exists() {
|
||||
dst = dir.join(format!("{stem}-copy-{n}{ext}"));
|
||||
n += 1;
|
||||
}
|
||||
std::fs::copy(src, &dst).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let new = s.catalog.alloc_photo_id();
|
||||
let mut q = ph.clone();
|
||||
q.id = new;
|
||||
q.source = Source::File { path: dst.to_string_lossy().to_string() };
|
||||
q.file_name = dst.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// the same bytes, but its own photo: no duplicate-content clash, no shared history
|
||||
q.content_hash = q.content_hash.map(|h| format!("{h}:dup{}", new.0));
|
||||
q.copy_of = None;
|
||||
q.copy_name = None;
|
||||
q.history.clear();
|
||||
q.versions.retain(|v| !v.auto);
|
||||
let mut ops = vec![Op::AddPhoto { photo: Box::new(q) }];
|
||||
for a in s.catalog.albums().filter(|a| !a.is_smart() && !a.folder && a.photos.contains(&id)) {
|
||||
let mut photos = a.photos.clone();
|
||||
photos.push(new);
|
||||
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
|
||||
}
|
||||
s.commit("Duplicate", Op::Batch { ops })?;
|
||||
made.push(new);
|
||||
}
|
||||
s.merge_undo(made.len(), "Duplicate");
|
||||
if let Some(last) = made.last() {
|
||||
s.selection = crate::Selection { ids: made.clone(), active: Some(*last) };
|
||||
}
|
||||
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.duplicate",
|
||||
"Duplicate",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — copy each photo's file next to it (`-copy`) and add it with the same settings, metadata and albums (a real file, unlike a virtual copy) → {ids}",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
cmd!(
|
||||
"photo.reload",
|
||||
"Reload from Disk",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
cmd!(
|
||||
"photo.editExternal",
|
||||
"Edit Copy for External Editor",
|
||||
[],
|
||||
None,
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
has_active,
|
||||
edit_external
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Assisted culling: score photos for focus and clipping, group similar shots taken close
|
||||
//! together (bursts) and mark the sharpest of each group; optionally reject blurry ones and pick
|
||||
//! each group's best. Scores are kept on the photos (rules / smart albums: `sharpness`,
|
||||
//! `bestOfGroup`).
|
||||
|
||||
use lightcraft_catalog::{Analysis, Flag, Op, PhotoId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, cmd};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Seconds between shots of the same burst at most.
|
||||
const BURST_GAP: i64 = 10;
|
||||
/// Signature similarity for "the same scene".
|
||||
const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
|
||||
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
|
||||
let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
for id in ids {
|
||||
match s.source_now(id, crate::media::SourceLevel::Thumb) {
|
||||
Ok(src) => {
|
||||
let t = s.catalog.photo(id).and_then(|p| p.captured.as_deref().and_then(secs));
|
||||
use lightcraft_pipeline::cull;
|
||||
rows.push((id, t, cull::sharpness(&src), cull::clipped(&src), cull::signature(&src)));
|
||||
}
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
// bursts: consecutive (by capture time) look-alike shots
|
||||
rows.sort_by_key(|r| (r.1.unwrap_or(i64::MAX), r.0));
|
||||
let mut group_of: Vec<Option<u32>> = vec![None; rows.len()];
|
||||
let mut next = 1u32;
|
||||
for i in 1..rows.len() {
|
||||
let (a, b) = (&rows[i - 1], &rows[i]);
|
||||
let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP);
|
||||
if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME {
|
||||
let g = *group_of[i - 1].get_or_insert_with(|| {
|
||||
next += 1;
|
||||
next - 1
|
||||
});
|
||||
group_of[i] = Some(g);
|
||||
}
|
||||
}
|
||||
let mut best: std::collections::HashMap<u32, (usize, f32)> = Default::default();
|
||||
for (i, g) in group_of.iter().enumerate() {
|
||||
if let Some(g) = g {
|
||||
let e = best.entry(*g).or_insert((i, -1.0));
|
||||
if rows[i].2 > e.1 {
|
||||
*e = (i, rows[i].2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let (mut rejected, mut picked) = (0, 0);
|
||||
let mut out = Vec::new();
|
||||
for (i, (id, _, sharp, clip, _)) in rows.iter().enumerate() {
|
||||
let g = group_of[i];
|
||||
let is_best = g.is_some_and(|g| best.get(&g).is_some_and(|b| b.0 == i));
|
||||
ops.push(Op::SetAnalysis { id: *id, analysis: Some(Analysis { sharpness: *sharp, clipped: *clip, group: g, best: is_best }) });
|
||||
let flag = s.catalog.photo(*id).map(|p| p.flag).unwrap_or_default();
|
||||
if reject_below.is_some_and(|t| *sharp < t) && flag != Flag::Reject {
|
||||
ops.push(Op::SetFlag { id: *id, flag: Flag::Reject });
|
||||
rejected += 1;
|
||||
} else if pick_best && is_best && flag == Flag::None {
|
||||
ops.push(Op::SetFlag { id: *id, flag: Flag::Pick });
|
||||
picked += 1;
|
||||
}
|
||||
out.push(json!({"id": id.0, "sharpness": (sharp * 10.0).round() / 10.0, "clipped": clip, "group": g, "best": is_best}));
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Assisted Culling", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by
|
||||
/// content), most similar first; the view is filtered to them.
|
||||
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findSimilar";
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
|
||||
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
|
||||
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
|
||||
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
|
||||
let key = s.catalog.photo(id).map(|p| crate::media::content_key(p))?;
|
||||
if let Some(v) = s.signatures.get(&key) {
|
||||
return Some(*v);
|
||||
}
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let v = lightcraft_pipeline::cull::signature(&src);
|
||||
s.signatures.insert(key, v);
|
||||
Some(v)
|
||||
};
|
||||
let me = sig(s, id).ok_or_else(|| super::bad(C, "can't read the photo"))?;
|
||||
let mut hits: Vec<(PhotoId, f32)> = Vec::new();
|
||||
for q in all {
|
||||
if let Some(v) = sig(s, q) {
|
||||
let sim = lightcraft_pipeline::cull::similarity(&me, &v);
|
||||
if sim >= min {
|
||||
hits.push((q, sim));
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {
|
||||
s.filter.only = hits.iter().map(|h| h.0).collect();
|
||||
}
|
||||
Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"library.findSimilar",
|
||||
"Find Similar Photos",
|
||||
["Photo"],
|
||||
None,
|
||||
"{id?, similarity?: 0..1 (0.8), filter?: bool (true)} — photos that look like the active one (composition and tones), most similar first; filters the view to them (Clear Filters to go back) → {photos: [{id, similarity}]}",
|
||||
always,
|
||||
find_similar
|
||||
),
|
||||
cmd!(
|
||||
"photo.analyze",
|
||||
"Assisted Culling",
|
||||
[],
|
||||
None,
|
||||
"{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}",
|
||||
always,
|
||||
analyze
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
|
||||
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
|
||||
//! exported as small JSON files).
|
||||
|
||||
use lightcraft_develop::ToneCurve;
|
||||
use lightcraft_geom::Point;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Channels `curve.reset` accepts.
|
||||
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
|
||||
|
||||
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
|
||||
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
|
||||
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
|
||||
let d = ToneCurve::default();
|
||||
match channel {
|
||||
"all" => *c = d,
|
||||
"point" => {
|
||||
c.master.clear();
|
||||
c.red.clear();
|
||||
c.green.clear();
|
||||
c.blue.clear();
|
||||
}
|
||||
"parametric" => {
|
||||
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
|
||||
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
|
||||
}
|
||||
"master" | "rgb" | "luma" => c.master.clear(),
|
||||
"red" => c.red.clear(),
|
||||
"green" => c.green.clear(),
|
||||
"blue" => c.blue.clear(),
|
||||
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.reset";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let channel = str_param(p, "channel").unwrap_or("all");
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
|
||||
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
|
||||
s.set_develop(id, d, &label)?;
|
||||
Ok(json!({"channel": channel}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"curve.reset",
|
||||
"Reset Tone Curve",
|
||||
[],
|
||||
None,
|
||||
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
|
||||
has_active,
|
||||
reset
|
||||
),
|
||||
cmd!(
|
||||
query "curve.presets",
|
||||
"Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"curve.applyPreset",
|
||||
"Apply Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
|
||||
has_active,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.savePreset",
|
||||
"Save Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
|
||||
has_active,
|
||||
save_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.deletePreset",
|
||||
"Delete Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — a user preset (built-ins can't be deleted)",
|
||||
always,
|
||||
delete_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.importPresets",
|
||||
"Import Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
|
||||
always,
|
||||
import_presets
|
||||
),
|
||||
cmd!(
|
||||
"curve.exportPresets",
|
||||
"Export Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
|
||||
always,
|
||||
export_presets
|
||||
),
|
||||
]
|
||||
}
|
||||
// ---------------------------------------------------------------- point-curve presets
|
||||
|
||||
/// File extension of exported curve presets (JSON).
|
||||
pub const CURVE_PRESET_EXT: &str = "lccurve";
|
||||
/// `format` tag of an exported curve-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.curvePresets";
|
||||
|
||||
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
|
||||
/// Built-ins ship with the app; the user's are saved with the library preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CurvePreset {
|
||||
pub name: String,
|
||||
pub master: Vec<[f64; 2]>,
|
||||
pub red: Vec<[f64; 2]>,
|
||||
pub green: Vec<[f64; 2]>,
|
||||
pub blue: Vec<[f64; 2]>,
|
||||
/// Built-in presets can't be deleted or overwritten (never stored).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
|
||||
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let mut out: Vec<[f64; 2]> =
|
||||
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
|
||||
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
|
||||
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
|
||||
out.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
|
||||
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
|
||||
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
impl CurvePreset {
|
||||
/// The point curves of `c` under `name`.
|
||||
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
|
||||
CurvePreset {
|
||||
name: name.into(),
|
||||
master: from_points(&c.master),
|
||||
red: from_points(&c.red),
|
||||
green: from_points(&c.green),
|
||||
blue: from_points(&c.blue),
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
|
||||
pub fn apply(&self, c: &mut ToneCurve) {
|
||||
c.master = to_points(&self.master);
|
||||
c.red = to_points(&self.red);
|
||||
c.green = to_points(&self.green);
|
||||
c.blue = to_points(&self.blue);
|
||||
}
|
||||
/// Does `c` carry exactly this preset's point curves?
|
||||
pub fn matches(&self, c: &ToneCurve) -> bool {
|
||||
let same = |a: &[[f64; 2]], b: &[Point]| {
|
||||
let (a, b) = (normalize(a), from_points(b));
|
||||
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
|
||||
};
|
||||
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
|
||||
}
|
||||
fn is_linear(&self) -> bool {
|
||||
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
|
||||
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
|
||||
pub fn builtin_presets() -> Vec<CurvePreset> {
|
||||
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
|
||||
vec![
|
||||
s("Linear", Vec::new()),
|
||||
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
|
||||
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Built-in presets, then the user's.
|
||||
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
|
||||
let mut v = builtin_presets();
|
||||
v.extend(s.curve_presets.iter().cloned());
|
||||
v
|
||||
}
|
||||
|
||||
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
|
||||
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
|
||||
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
|
||||
}
|
||||
|
||||
fn is_builtin_name(name: &str) -> bool {
|
||||
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) a user preset.
|
||||
fn upsert(s: &mut Session, p: CurvePreset) {
|
||||
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
|
||||
Some(x) => *x = p,
|
||||
None => s.curve_presets.push(p),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
|
||||
json!({"presets": all_presets(s), "current": current})
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.applyPreset";
|
||||
let name = name_param(p, C)?;
|
||||
let preset =
|
||||
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
preset.apply(&mut d.curve);
|
||||
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
|
||||
Ok(json!({"name": preset.name}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.savePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
|
||||
}
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
|
||||
if preset.is_linear() {
|
||||
return Err(bad(C, "the point curve is linear: shape it first"));
|
||||
}
|
||||
upsert(s, preset);
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.deletePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, "built-in presets can't be deleted"));
|
||||
}
|
||||
let n = s.curve_presets.len();
|
||||
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if n == s.curve_presets.len() {
|
||||
return Err(bad(C, format!("no curve preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let items = match &v {
|
||||
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
|
||||
Value::Array(_) => v.clone(),
|
||||
Value::Object(_) => Value::Array(vec![v.clone()]),
|
||||
_ => return Err("not a curve preset file".into()),
|
||||
};
|
||||
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no curve presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The JSON of a curve-preset file holding `presets`.
|
||||
pub fn to_file(presets: &[CurvePreset]) -> String {
|
||||
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
|
||||
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.importPresets";
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(x) = str_param(p, "path") {
|
||||
paths.push(x.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "missing `path` / `paths`"));
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for f in &paths {
|
||||
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
|
||||
Ok(list) => {
|
||||
for mut preset in list {
|
||||
preset.name = preset.name.trim().to_string();
|
||||
preset.builtin = false;
|
||||
if is_builtin_name(&preset.name) {
|
||||
preset.name = format!("{} (imported)", preset.name);
|
||||
}
|
||||
imported.push(preset.name.clone());
|
||||
upsert(s, preset);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
if !imported.is_empty() {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.exportPresets";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let mut path = std::path::PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(CURVE_PRESET_EXT);
|
||||
}
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let chosen: Vec<CurvePreset> = match &names {
|
||||
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
|
||||
None => s.curve_presets.clone(),
|
||||
};
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
@@ -193,6 +193,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"develop.autoBwMix",
|
||||
"Auto B&W Mix",
|
||||
[],
|
||||
None,
|
||||
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
|
||||
has_active,
|
||||
|s, _| {
|
||||
let id = active(s, "develop.autoBwMix")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
|
||||
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
|
||||
d.treatment = Treatment::Bw;
|
||||
let b = &mut d.bw_mix;
|
||||
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"mix": m}))
|
||||
}
|
||||
),
|
||||
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
|
||||
let id = active(s, "develop.auto")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
|
||||
@@ -288,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string();
|
||||
if !crate::presets::PROFILES.iter().any(|x| x.id == id) {
|
||||
if !crate::presets::PROFILES.iter().any(|x| x.id == id) && !s.lut_profiles.iter().any(|x| x.id == id) {
|
||||
return Err(bad("develop.profile", format!("unknown profile `{id}`")));
|
||||
}
|
||||
edit(s, "develop.profile", "Profile", |d| {
|
||||
@@ -301,7 +323,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string();
|
||||
if crate::presets::profile(&id).is_none() {
|
||||
if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) {
|
||||
return Err(bad("profile.favorite", format!("unknown profile `{id}`")));
|
||||
}
|
||||
let on = s.profile_favorites.contains(&id);
|
||||
@@ -616,6 +638,92 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.quickAdjust",
|
||||
"Quick Develop",
|
||||
[],
|
||||
None,
|
||||
"{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "develop.quickAdjust";
|
||||
let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string();
|
||||
let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?;
|
||||
let delta = f64_req(p, "delta", c)?;
|
||||
let label = format!("Quick Develop: {}", spec.label);
|
||||
let ops: Vec<Op> = s
|
||||
.targets(p)
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
nd.wb.mode = WbMode::Custom;
|
||||
}
|
||||
s.develop_op(id, nd, &label)
|
||||
})
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(&label, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.matchExposure",
|
||||
"Match Total Exposures",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "develop.matchExposure";
|
||||
let reference = active(s, c)?;
|
||||
// log2 of the light the photo gathered (shutter s × ISO / f-number²)
|
||||
let gathered = |s: &Session, id: PhotoId| -> Option<f64> {
|
||||
let m = &s.catalog.photo(id)?.meta;
|
||||
let t = match m.shutter.split_once('/') {
|
||||
Some((a, b)) => a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?,
|
||||
None => m.shutter.trim().trim_end_matches('s').trim().parse::<f64>().ok()?,
|
||||
};
|
||||
let (n, iso) = (m.aperture? as f64, m.iso? as f64);
|
||||
(t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2())
|
||||
};
|
||||
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
|
||||
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
|
||||
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
|
||||
for id in ids {
|
||||
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
|
||||
skipped.push(id.0);
|
||||
continue;
|
||||
};
|
||||
let mut nd = (*d).clone();
|
||||
nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0);
|
||||
ops.extend(s.develop_op(id, nd, "Match Total Exposures"));
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Match Total Exposures", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "skipped": skipped}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.autoSync",
|
||||
"Auto Sync",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+Shift+A"),
|
||||
"{on?: bool} (toggles when omitted) — edits to the active photo also change the other selected photos (only the settings that changed; not spot removal / red eye) → {on}",
|
||||
always,
|
||||
|s, p| {
|
||||
s.auto_sync = p.get("on").and_then(Value::as_bool).unwrap_or(!s.auto_sync);
|
||||
Ok(json!({"on": s.auto_sync}))
|
||||
}
|
||||
),
|
||||
// ---- presets
|
||||
cmd!("preset.apply", "Apply Preset", [], None, "{id: presetId, amount?: 0..200 (percent), ids?}", has_selection, |s, p| {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Filter presets: the filter bar's settings saved under a name ("Five stars", "Unedited raws")
|
||||
//! and applied in one step. Saved with the library.
|
||||
|
||||
use lightcraft_catalog::Filter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FilterPreset {
|
||||
pub name: String,
|
||||
pub filter: Filter,
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
json!(s.filter_presets)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "filter.savePreset";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
if s.filter == Filter::default() {
|
||||
return Err(bad(C, "the filter is empty: set some filters first"));
|
||||
}
|
||||
let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };
|
||||
match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(f) => *f = preset,
|
||||
None => s.filter_presets.push(preset),
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "filter.applyPreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;
|
||||
s.filter = f.filter.clone();
|
||||
Ok(json!({"photos": s.visible().len()}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "filter.deletePreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let n = s.filter_presets.len();
|
||||
s.filter_presets.retain(|f| !f.name.eq_ignore_ascii_case(name));
|
||||
if n == s.filter_presets.len() {
|
||||
return Err(bad(C, format!("no filter preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "filter.presets", "Filter Presets", [], None, "{} → [{name, filter}]", always, |s, _| Ok(list(s))),
|
||||
cmd!(
|
||||
"filter.savePreset",
|
||||
"Save Filter Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the current filter (library.filter), saved with the library",
|
||||
always,
|
||||
save
|
||||
),
|
||||
cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),
|
||||
cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge.
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
||||
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use serde_json::{Value, json};
|
||||
@@ -30,8 +31,139 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// A named set of up to nine keywords.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KeywordSet {
|
||||
pub name: String,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// The set name meaning "the nine most recently added keywords".
|
||||
pub const RECENT: &str = "Recent Keywords";
|
||||
|
||||
/// Remember `added` as the most recent keywords (newest first, nine kept).
|
||||
pub fn note_recent(s: &mut Session, added: &[String]) {
|
||||
for k in added.iter().rev() {
|
||||
let k = clean(k);
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
s.recent_keywords.insert(0, k);
|
||||
}
|
||||
s.recent_keywords.truncate(9);
|
||||
let _ = s.save_prefs();
|
||||
}
|
||||
|
||||
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
||||
pub fn current_keywords(s: &Session) -> Vec<String> {
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
|
||||
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
||||
v.truncate(9);
|
||||
v
|
||||
}
|
||||
|
||||
/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first).
|
||||
pub fn keyword_sets_json(s: &Session) -> Value {
|
||||
let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})];
|
||||
sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords})));
|
||||
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
||||
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
||||
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
||||
.name
|
||||
.clone(),
|
||||
)
|
||||
};
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}),
|
||||
cmd!(
|
||||
"keyword.saveSet",
|
||||
"Save Keyword Set",
|
||||
[],
|
||||
None,
|
||||
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
|
||||
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
||||
.to_string();
|
||||
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
|
||||
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
|
||||
} else {
|
||||
current_keywords(s)
|
||||
};
|
||||
keywords.truncate(9);
|
||||
let set = KeywordSet { name: name.clone(), keywords };
|
||||
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
}
|
||||
s.keyword_set = Some(name);
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}
|
||||
),
|
||||
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
||||
let before = s.keyword_sets.len();
|
||||
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
if s.keyword_sets.len() == before {
|
||||
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
||||
}
|
||||
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
s.keyword_set = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}),
|
||||
cmd!(
|
||||
"keyword.toggleFromSet",
|
||||
"Toggle Keyword from Set",
|
||||
[],
|
||||
None,
|
||||
"{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it",
|
||||
super::has_selection,
|
||||
|s, p| {
|
||||
let i = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|i| (1..=9).contains(i))
|
||||
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
|
||||
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
let key = if all { "removeKeywords" } else { "addKeywords" };
|
||||
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
||||
let recent = s.recent_keywords.clone();
|
||||
let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?;
|
||||
if s.keyword_set.is_none() {
|
||||
s.recent_keywords = recent;
|
||||
}
|
||||
r["keyword"] = json!(k);
|
||||
r["added"] = json!(!all);
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| {
|
||||
Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default())
|
||||
}),
|
||||
|
||||
@@ -128,34 +128,43 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// ---- view source / filter / sort
|
||||
cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("all");
|
||||
s.source = match kind {
|
||||
"all" => LibrarySource::All,
|
||||
"recentlyAdded" => LibrarySource::RecentlyAdded,
|
||||
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
|
||||
"picks" => LibrarySource::Picks,
|
||||
"album" => {
|
||||
let a = album_param(p, "id", "library.source")?;
|
||||
if s.catalog.album(a).is_none_or(|a| a.folder) {
|
||||
return Err(bad("library.source", "no such album"));
|
||||
cmd!(
|
||||
"library.source",
|
||||
"Show Source",
|
||||
[],
|
||||
None,
|
||||
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
|
||||
always,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("all");
|
||||
s.source = match kind {
|
||||
"all" => LibrarySource::All,
|
||||
"recentlyAdded" => LibrarySource::RecentlyAdded,
|
||||
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
|
||||
"picks" => LibrarySource::Picks,
|
||||
"missing" => LibrarySource::Missing,
|
||||
"album" => {
|
||||
let a = album_param(p, "id", "library.source")?;
|
||||
if s.catalog.album(a).is_none_or(|a| a.folder) {
|
||||
return Err(bad("library.source", "no such album"));
|
||||
}
|
||||
LibrarySource::Album(a)
|
||||
}
|
||||
LibrarySource::Album(a)
|
||||
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
|
||||
};
|
||||
let vis = s.visible_cloned();
|
||||
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
}
|
||||
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
|
||||
};
|
||||
let vis = s.visible_cloned();
|
||||
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
Ok(json!({"count": vis.len()}))
|
||||
}
|
||||
Ok(json!({"count": vis.len()}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"library.filter",
|
||||
"Filter",
|
||||
[],
|
||||
None,
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
@@ -361,16 +370,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Info",
|
||||
[],
|
||||
None,
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let strs =
|
||||
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
|
||||
let targets = s.targets(p);
|
||||
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
|
||||
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(t)) if t.trim().is_empty() => Some(None),
|
||||
Some(Value::String(t)) => Some(Some(
|
||||
parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,
|
||||
)),
|
||||
Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {
|
||||
(Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),
|
||||
_ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),
|
||||
},
|
||||
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
|
||||
};
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let mut m = ph.meta.clone();
|
||||
if let Some(g) = gps {
|
||||
m.gps = g;
|
||||
}
|
||||
for (k, field) in [
|
||||
("title", &mut m.title),
|
||||
("caption", &mut m.caption),
|
||||
@@ -402,6 +428,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let n = ops.len();
|
||||
s.commit("Edit Info", Op::Batch { ops })?;
|
||||
// keywords just added become the Recent Keywords set
|
||||
let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();
|
||||
if n > 0 && !added.is_empty() {
|
||||
crate::cmd::keywords::note_recent(s, &added);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
@@ -418,7 +449,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
@@ -427,7 +458,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Smart Album…",
|
||||
[],
|
||||
None,
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
|
||||
@@ -445,6 +476,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
let k = match kind {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(
|
||||
"album.setRules",
|
||||
"Edit Smart Album",
|
||||
@@ -504,6 +556,145 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added}))
|
||||
}),
|
||||
cmd!(
|
||||
"album.toggleTarget",
|
||||
"Add to Target Album",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let album = Album { quick: true, ..Album::new(id, "Quick Collection") };
|
||||
s.commit("New Quick Collection", Op::AddAlbum { album })?;
|
||||
id
|
||||
}
|
||||
};
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
|
||||
let photos: Vec<PhotoId> = if all_in {
|
||||
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
|
||||
} else {
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
|
||||
};
|
||||
let name = al.name.clone();
|
||||
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
|
||||
s.commit(
|
||||
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
|
||||
)?;
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
|
||||
s.target_album = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(a) => {
|
||||
let id = AlbumId(a);
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.setTarget", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({"target": s.target_album.map(|a| a.0)}))
|
||||
}),
|
||||
cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| {
|
||||
let Some(id) = s.catalog.quick_collection() else { return ok() };
|
||||
s.commit(
|
||||
"Clear Quick Collection",
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] },
|
||||
)?;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
"library.autoImport",
|
||||
"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",
|
||||
always,
|
||||
|s, p| {
|
||||
if let Some(f) = p.get("folder") {
|
||||
s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) {
|
||||
Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()),
|
||||
Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
|
||||
s.import_defaults.auto_copy = c;
|
||||
}
|
||||
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}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.autoImportScan",
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{} — 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}",
|
||||
always,
|
||||
|s, _| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
// only files that stopped growing: a file still being written is left for later
|
||||
let known: std::collections::HashSet<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut fresh = Vec::new();
|
||||
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
|
||||
let path = e.path();
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
// each file is tried once (a non-photo isn't retried every scan)
|
||||
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
|
||||
continue;
|
||||
}
|
||||
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let seen = s.auto_import_seen.insert(ps.clone(), size);
|
||||
if size > 0 && seen == Some(size) {
|
||||
fresh.push(ps);
|
||||
}
|
||||
}
|
||||
if fresh.is_empty() {
|
||||
return Ok(json!({"imported": [], "folder": folder}));
|
||||
}
|
||||
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
||||
let mut params = json!({"paths": fresh, "mode": mode});
|
||||
if let Some(a) = s.import_defaults.auto_album.clone() {
|
||||
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
|
||||
Some(al) => params["album"] = json!(al.id.0),
|
||||
None => params["albumName"] = json!(a),
|
||||
}
|
||||
}
|
||||
for f in &fresh {
|
||||
s.auto_import_seen.insert(f.clone(), u64::MAX);
|
||||
}
|
||||
let sel = s.selection.clone();
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
s.selection = sel;
|
||||
Ok(json!({"imported": r["imported"], "folder": folder}))
|
||||
}
|
||||
),
|
||||
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
|
||||
let id = album_param(p, "id", "album.removePhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
@@ -527,7 +718,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Review Import",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added",
|
||||
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -544,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Photos…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -568,7 +759,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => {
|
||||
crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let opts = crate::import::ImportOptions {
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
destination: str_param(p, "destination").map(str::to_string),
|
||||
organize,
|
||||
rename: str_param(p, "rename").map(str::to_string),
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: super::bool_or(p, "dng", false),
|
||||
..Default::default()
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
@@ -582,6 +796,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(a) = album
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
// (album.addPhotos wants a selection: the new photos are about to be it)
|
||||
s.selection = Selection::single(PhotoId(imported[0]));
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
||||
report["album"] = json!(a);
|
||||
}
|
||||
@@ -653,7 +869,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
|
||||
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
|
||||
return Err(bad(c, problem));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
||||
@@ -697,3 +917,48 @@ impl Session {
|
||||
fn has_library(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
|
||||
}
|
||||
|
||||
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
|
||||
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
|
||||
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
|
||||
let t = t.trim();
|
||||
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
|
||||
let (a, b) = match t.split_once(',') {
|
||||
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
|
||||
None => {
|
||||
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
|
||||
(t[..i].trim().to_string(), t[i..].trim().to_string())
|
||||
}
|
||||
};
|
||||
let one = |s: &str, pos: char, neg: char| -> Option<f64> {
|
||||
let up = s.to_ascii_uppercase();
|
||||
let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 };
|
||||
let nums: Vec<f64> =
|
||||
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
|
||||
let v = match nums.as_slice() {
|
||||
[d] => *d,
|
||||
[d, m] => d + m / 60.0,
|
||||
[d, m, s] => d + m / 60.0 + s / 3600.0,
|
||||
_ => return None,
|
||||
};
|
||||
let _ = pos;
|
||||
Some(sign * v)
|
||||
};
|
||||
let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?);
|
||||
(la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gps_tests {
|
||||
#[test]
|
||||
fn coordinates_people_type() {
|
||||
let p = super::parse_gps;
|
||||
assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276)));
|
||||
let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap();
|
||||
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
|
||||
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
|
||||
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
|
||||
assert_eq!(p("95, 10"), None);
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Imported profiles: `.cube` 3D LUTs used as creative profiles (Amount 0–200 % like the
|
||||
//! built-in ones). A library on disk keeps a copy of each file in its `Profiles/` folder; the
|
||||
//! list lives in the library's prefs and the LUTs are registered with the pipeline when the
|
||||
//! library opens.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// One imported LUT profile.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LutProfile {
|
||||
/// `lut:<slug>`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
/// The `.cube` file (the library's copy when there is a library on disk).
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
fn profiles_dir(s: &Session) -> Option<PathBuf> {
|
||||
s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Profiles"))
|
||||
}
|
||||
|
||||
/// Register every imported profile's LUT with the pipeline (missing files are skipped).
|
||||
pub fn register_all(s: &Session) {
|
||||
for p in &s.lut_profiles {
|
||||
if let Some(l) = std::fs::read_to_string(&p.file).ok().and_then(|t| lightcraft_pipeline::lut::Lut3d::parse_cube(&t).ok()) {
|
||||
lightcraft_pipeline::lut::register(&p.id, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened).
|
||||
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
// (name, text, group)
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let ep = e.path();
|
||||
if ep.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
if ep.is_dir() {
|
||||
stack.push(ep);
|
||||
} else if ep.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"))
|
||||
&& let Ok(t) = std::fs::read_to_string(&ep)
|
||||
{
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
let Ok(b) = std::fs::read(path) else { continue };
|
||||
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
|
||||
if inner.to_ascii_lowercase().ends_with(".cube") {
|
||||
let ip = Path::new(&inner);
|
||||
let group = ip
|
||||
.parent()
|
||||
.and_then(|d| d.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.or_else(|| path.file_stem().map(|n| n.to_string_lossy().to_string()));
|
||||
out.push((
|
||||
ip.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
String::from_utf8_lossy(&data).to_string(),
|
||||
group,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Ok(t) = std::fs::read_to_string(path) {
|
||||
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "profile.import";
|
||||
let paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "no paths"));
|
||||
}
|
||||
let dir = profiles_dir(s);
|
||||
if let Some(d) = &dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for (name, text, group) in cube_files(&paths) {
|
||||
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
failed.push(json!([name, e]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let display = lut.title.clone().filter(|t| !t.trim().is_empty()).unwrap_or_else(|| name.clone());
|
||||
let group = super::super::preset_import::group_from_dir(&group.unwrap_or_default()).unwrap_or_else(|| "Imported".into());
|
||||
let slug = crate::presets::slug(&format!("{group}-{display}"));
|
||||
let mut id = format!("lut:{slug}");
|
||||
let mut n = 2;
|
||||
while s.lut_profiles.iter().any(|x| x.id == id) {
|
||||
id = format!("lut:{slug}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
let file = match &dir {
|
||||
Some(d) => {
|
||||
let f = d.join(format!("{}.cube", id.trim_start_matches("lut:")));
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
None => {
|
||||
// no library on disk: remember the original (the in-memory session ends with the app)
|
||||
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
};
|
||||
lightcraft_pipeline::lut::register(&id, lut);
|
||||
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
|
||||
imported.push(json!({"id": id, "name": display, "group": group}));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"profile.import",
|
||||
"Import Profiles",
|
||||
[],
|
||||
None,
|
||||
"{paths: [.cube file, folder or .zip]} — 3D LUTs become creative profiles (Amount 0–200 %; rendered on the CPU) in the profile browser's groups (folder / zip name, else Imported) → {imported: [{id, name, group}], failed}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
cmd!("profile.deleteImported", "Delete Imported Profile", [], None, "{id: lut:…}", always, |s, p| {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("profile.deleteImported", "missing id"))?.to_string();
|
||||
let Some(i) = s.lut_profiles.iter().position(|x| x.id == id) else {
|
||||
return Err(bad("profile.deleteImported", format!("no imported profile `{id}`")));
|
||||
};
|
||||
let gone = s.lut_profiles.remove(i);
|
||||
if profiles_dir(s).is_some_and(|d| Path::new(&gone.file).starts_with(d)) {
|
||||
let _ = std::fs::remove_file(&gone.file);
|
||||
}
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"deleted": id}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photo management commands: batch rename, capture time, colour label names.
|
||||
//! Photo management commands: batch rename, capture time, colour label names and sets.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -13,6 +13,52 @@ fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft
|
||||
Ok((s.targets(p), template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LabelSet {
|
||||
pub name: String,
|
||||
pub names: [String; 5],
|
||||
}
|
||||
|
||||
/// Our built-in sets.
|
||||
pub fn builtin_label_sets() -> Vec<LabelSet> {
|
||||
let set = |name: &str, n: [&str; 5]| LabelSet { name: name.into(), names: n.map(str::to_string) };
|
||||
vec![set("Colors", ["", "", "", "", ""]), set("Review", ["Reject", "Needs Work", "Approved", "Retouch", "Print"])]
|
||||
}
|
||||
|
||||
fn all_label_sets(s: &crate::Session) -> Vec<LabelSet> {
|
||||
builtin_label_sets().into_iter().chain(s.label_sets.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// The set whose names are the catalog's current ones.
|
||||
fn current_label_set(s: &crate::Session) -> Option<String> {
|
||||
let cur: [String; 5] = ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string());
|
||||
all_label_sets(s)
|
||||
.into_iter()
|
||||
.find(|x| x.names.iter().zip(&cur).all(|(a, b)| a.trim() == b.trim() || (a.trim().is_empty() && b.is_empty())))
|
||||
.map(|x| x.name)
|
||||
}
|
||||
|
||||
/// Every set (built-ins first) and the one in use: `{sets: [{name, names, builtin}], current}`.
|
||||
pub fn label_sets_json(s: &crate::Session) -> Value {
|
||||
let builtin = builtin_label_sets().len();
|
||||
let sets: Vec<Value> =
|
||||
all_label_sets(s).iter().enumerate().map(|(i, x)| json!({"name": x.name, "names": x.names, "builtin": i < builtin})).collect();
|
||||
json!({"sets": sets, "current": current_label_set(s)})
|
||||
}
|
||||
|
||||
fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
ColorLabel::ALL
|
||||
.iter()
|
||||
.zip(names)
|
||||
.filter_map(|(l, n)| {
|
||||
let n = n.trim();
|
||||
let name = (!n.is_empty() && !n.eq_ignore_ascii_case(&format!("{l:?}"))).then(|| n.to_string());
|
||||
(s.catalog.custom_label_name(*l) != name.as_deref()).then_some(Op::SetLabelName { label: *l, name })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
@@ -112,5 +158,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
),
|
||||
cmd!(query "label.sets", "Color Label Sets", [], None, "{} → {sets: [{name, names: [red, yellow, green, blue, purple], builtin}], current: name|null}", always, |s, _| Ok(label_sets_json(s))),
|
||||
cmd!("label.applySet", "Apply Color Label Set", [], None, "{name} — use a set's label names (undoable)", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("label.applySet", "missing `name`"))?;
|
||||
let set = all_label_sets(s)
|
||||
.into_iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("label.applySet", format!("no label set `{name}`")))?;
|
||||
let ops = set_names_ops(s, &set.names);
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(&format!("Label Set: {}", set.name), Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}),
|
||||
cmd!(
|
||||
"label.saveSet",
|
||||
"Save Color Label Set",
|
||||
[],
|
||||
None,
|
||||
"{name} — save the current label names as a set (replaces a user set of that name)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name =
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("label.saveSet", "missing `name`"))?.to_string();
|
||||
if builtin_label_sets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad("label.saveSet", format!("`{name}` is a built-in set")));
|
||||
}
|
||||
let set = LabelSet { name: name.clone(), names: ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string()) };
|
||||
match s.label_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.label_sets.push(set),
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
),
|
||||
cmd!("label.deleteSet", "Delete Color Label Set", [], None, "{name} — user sets only", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("label.deleteSet", "missing `name`"))?;
|
||||
let before = s.label_sets.len();
|
||||
s.label_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
if s.label_sets.len() == before {
|
||||
return Err(bad("label.deleteSet", format!("no user label set `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
masks.push(Mask {
|
||||
id: next,
|
||||
name: name.unwrap_or_else(|| format!("Mask {next}")),
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
..Default::default()
|
||||
});
|
||||
*active = Some(next);
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mid = mask_id(p, active, "mask.addComponent")?;
|
||||
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
|
||||
let i = find(masks, mid, "mask.addComponent")?;
|
||||
masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape });
|
||||
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -144,12 +144,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let m = &mut masks[i];
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
} else {
|
||||
m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } });
|
||||
m.components.push(MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Brush { strokes: vec![stroke] },
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -167,6 +172,64 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"mask.sampleColor",
|
||||
"Sample Mask Color",
|
||||
[],
|
||||
None,
|
||||
"{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.sampleColor";
|
||||
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
|
||||
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
|
||||
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
|
||||
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let mut out = Vec::new();
|
||||
masks_edit(s, c, "Color Range", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
let k = comp
|
||||
.or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. })))
|
||||
.ok_or_else(|| bad(c, "the mask has no colour range"))?;
|
||||
let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else {
|
||||
return Err(bad(c, "not a colour range component"));
|
||||
};
|
||||
if !add {
|
||||
samples.clear();
|
||||
}
|
||||
if samples.len() < 5 {
|
||||
samples.push(lab);
|
||||
}
|
||||
out = samples.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"samples": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.refine",
|
||||
"Refine Mask Edges",
|
||||
[],
|
||||
None,
|
||||
"{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0);
|
||||
let mid = mask_id(p, s.active_mask, "mask.refine")?;
|
||||
masks_edit(s, "mask.refine", "Refine Edges", |masks, _| {
|
||||
let i = find(masks, mid, "mask.refine")?;
|
||||
masks[i].refine = v;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.adjust",
|
||||
"Set Mask Adjustments",
|
||||
@@ -258,6 +321,54 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"mask.component",
|
||||
"Edit Mask Component",
|
||||
[],
|
||||
None,
|
||||
"{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.component";
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
|
||||
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
|
||||
let op: Option<MaskOp> = match p.get("op") {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
|
||||
None => None,
|
||||
};
|
||||
let label = match action.as_str() {
|
||||
"invert" => "Invert Component",
|
||||
"duplicate" => "Duplicate Component",
|
||||
"delete" => "Delete Component",
|
||||
"rename" => "Rename Component",
|
||||
"op" => "Change Component Mode",
|
||||
a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))),
|
||||
};
|
||||
masks_edit(s, c, label, |masks, active| {
|
||||
let i = find(masks, mid, c)?;
|
||||
let comps = &mut masks[i].components;
|
||||
let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?;
|
||||
match action.as_str() {
|
||||
"invert" => comp.invert = !comp.invert,
|
||||
"rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string),
|
||||
"op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?,
|
||||
"duplicate" => {
|
||||
let copy = comp.clone();
|
||||
comps.insert(k + 1, copy);
|
||||
}
|
||||
_ => {
|
||||
comps.remove(k);
|
||||
if comps.is_empty() {
|
||||
masks.remove(i);
|
||||
*active = masks.last().map(|m| m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| {
|
||||
let mid = mask_id(p, s.active_mask, "mask.move")?;
|
||||
let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default();
|
||||
@@ -279,6 +390,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}),
|
||||
// ---- Remove tool (spots)
|
||||
cmd!(
|
||||
"spot.findDust",
|
||||
"Find Dust Spots",
|
||||
[],
|
||||
None,
|
||||
"{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.findDust";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
// the uncropped photo, so positions are the spots' own coordinates
|
||||
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
|
||||
let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32);
|
||||
let out: Vec<Value> = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect();
|
||||
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
|
||||
return Ok(json!({"spots": out, "added": 0}));
|
||||
}
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let base = Spot::default();
|
||||
for d in &found {
|
||||
let mut spot = Spot {
|
||||
mode: SpotMode::Heal,
|
||||
points: vec![Point::new(d.x, d.y)],
|
||||
size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: base.feather,
|
||||
opacity: 100.0,
|
||||
source_offset: None,
|
||||
};
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
dd.spots.push(spot);
|
||||
}
|
||||
s.set_develop(id, dd, "Find Dust Spots")?;
|
||||
Ok(json!({"spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.add",
|
||||
"Add Remove Spot",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…)
|
||||
//! applied to photos in one step or to every import (Settings → Import). Saved with the library.
|
||||
|
||||
use lightcraft_catalog::Meta;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The fields a metadata preset can hold (photo.setMeta names).
|
||||
pub const FIELDS: &[&str] = &["title", "caption", "copyright", "creator", "location", "city", "state", "country", "keywords"];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MetadataPreset {
|
||||
pub name: String,
|
||||
/// photo.setMeta fields; `keywords` are added, not replaced.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
Some(match k {
|
||||
"title" => &mut m.title,
|
||||
"caption" => &mut m.caption,
|
||||
"copyright" => &mut m.copyright,
|
||||
"creator" => &mut m.creator,
|
||||
"location" => &mut m.location,
|
||||
"city" => &mut m.city,
|
||||
"state" => &mut m.state,
|
||||
"country" => &mut m.country,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply preset `fields` to `m`: text fields replace, keywords are added.
|
||||
pub fn apply_to(m: &mut Meta, fields: &Value) {
|
||||
for (k, v) in fields.as_object().into_iter().flatten() {
|
||||
if k == "keywords" {
|
||||
for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) {
|
||||
m.keywords.push(kw.to_string());
|
||||
}
|
||||
}
|
||||
} else if let (Some(f), Some(t)) = (text_field(m, k), v.as_str()) {
|
||||
*f = t.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
json!(s.metadata_presets)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.savePreset";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let fields = match p.get("fields") {
|
||||
Some(Value::Object(o)) => {
|
||||
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
|
||||
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
|
||||
}
|
||||
Value::Object(o.clone())
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`fields` must be an object")),
|
||||
// from the active photo: its non-empty fields among `only` (default: copyright, creator, place)
|
||||
None => {
|
||||
let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?;
|
||||
let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone();
|
||||
let only: Vec<String> = p
|
||||
.get("only")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_else(|| ["copyright", "creator", "location", "city", "state", "country"].map(String::from).to_vec());
|
||||
let mut o = Map::new();
|
||||
for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) {
|
||||
if k == "keywords" {
|
||||
if !m.keywords.is_empty() {
|
||||
o.insert(k.clone(), json!(m.keywords));
|
||||
}
|
||||
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
|
||||
o.insert(k.clone(), json!(v.clone()));
|
||||
}
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
};
|
||||
let preset = MetadataPreset { name: name.clone(), fields };
|
||||
match s.metadata_presets.iter_mut().find(|m| m.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(m) => *m = preset,
|
||||
None => s.metadata_presets.push(preset),
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.deletePreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let n = s.metadata_presets.len();
|
||||
s.metadata_presets.retain(|m| !m.name.eq_ignore_ascii_case(name));
|
||||
if s.metadata_presets.len() == n {
|
||||
return Err(bad(C, format!("no metadata preset `{name}`")));
|
||||
}
|
||||
if s.import_defaults.metadata_preset.as_deref().is_some_and(|m| m.eq_ignore_ascii_case(name)) {
|
||||
s.import_defaults.metadata_preset = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// photo.setMeta with the preset's fields (keywords added) on `ids` / the selection.
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.applyPreset";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let preset = s
|
||||
.metadata_presets
|
||||
.iter()
|
||||
.find(|m| m.name.eq_ignore_ascii_case(name))
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(C, format!("no metadata preset `{name}`")))?;
|
||||
let mut params = Map::new();
|
||||
for (k, v) in preset.fields.as_object().into_iter().flatten() {
|
||||
params.insert(if k == "keywords" { "addKeywords".into() } else { k.clone() }, v.clone());
|
||||
}
|
||||
if let Some(ids) = p.get("ids") {
|
||||
params.insert("ids".into(), ids.clone());
|
||||
}
|
||||
s.execute("photo.setMeta", &Value::Object(params))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "metadata.presets", "Metadata Presets", [], None, "{} → [{name, fields}]", always, |s, _| Ok(list(s))),
|
||||
cmd!(
|
||||
"metadata.savePreset",
|
||||
"Save Metadata Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, fields?: {copyright?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default copyright, creator, place)} — adds or replaces",
|
||||
always,
|
||||
save
|
||||
),
|
||||
cmd!("metadata.deletePreset", "Delete Metadata Preset", [], None, "{name}", always, delete),
|
||||
cmd!(
|
||||
"metadata.applyPreset",
|
||||
"Apply Metadata Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?} — text fields replace, keywords are added; one undo step",
|
||||
always,
|
||||
apply
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -27,7 +27,7 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() } }
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -42,7 +42,15 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "only photos from files can be relinked"));
|
||||
}
|
||||
let abs = abs.to_string_lossy().to_string();
|
||||
s.commit("Relink Photo", relink_op(id, &abs))?;
|
||||
// the file may differ from the one imported (another export, a raw that decodes now or not):
|
||||
// its size, dimensions and preview-only state follow it
|
||||
let mut ops = vec![relink_op(id, &abs)];
|
||||
if s.media.file_probe.is_some()
|
||||
&& let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id))
|
||||
{
|
||||
ops.extend(crate::cmd::convert::content_op(id, ph, info));
|
||||
}
|
||||
s.commit("Relink Photo", if ops.len() == 1 { ops.pop().expect("one op") } else { Op::Batch { ops } })?;
|
||||
s.media.forget(id);
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
@@ -7,18 +7,25 @@
|
||||
mod before;
|
||||
mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
mod keywords;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
mod library;
|
||||
mod manage;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
mod masks;
|
||||
mod merge;
|
||||
mod missing;
|
||||
pub mod metadata;
|
||||
pub mod missing;
|
||||
mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod query;
|
||||
mod xmp;
|
||||
|
||||
@@ -112,10 +119,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(develop::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(curves::specs());
|
||||
v.extend(masks::specs());
|
||||
v.extend(organize::specs());
|
||||
v.extend(keywords::specs());
|
||||
v.extend(manage::specs());
|
||||
v.extend(previews::specs());
|
||||
v.extend(lut_profiles::specs());
|
||||
v.extend(cull::specs());
|
||||
v.extend(convert::specs());
|
||||
v.extend(convert::edit_specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(xmp::specs());
|
||||
@@ -125,6 +138,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
/// Stacks containing any of `targets`, in first-seen order.
|
||||
@@ -65,8 +65,8 @@ fn copy_number(name: Option<&str>) -> u32 {
|
||||
|
||||
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
|
||||
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
|
||||
/// copy is visible).
|
||||
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
|
||||
/// copy is visible). `name` replaces the default "Copy N".
|
||||
fn virtual_copy(s: &mut Session, src: PhotoId, name: Option<&str>) -> Result<Option<PhotoId>> {
|
||||
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
|
||||
let master = orig.copy_of.unwrap_or(src);
|
||||
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
|
||||
@@ -74,7 +74,7 @@ fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
|
||||
let mut c = (*orig).clone();
|
||||
c.id = id;
|
||||
c.copy_of = Some(master);
|
||||
c.copy_name = Some(format!("Copy {n}"));
|
||||
c.copy_name = Some(name.map_or_else(|| format!("Copy {n}"), str::to_string));
|
||||
c.versions.clear();
|
||||
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
|
||||
c.deleted = false;
|
||||
@@ -108,13 +108,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Virtual Copy",
|
||||
["Photo"],
|
||||
Some("Cmd+'"),
|
||||
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
|
||||
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
if let Some(id) = virtual_copy(s, t)? {
|
||||
if let Some(id) = virtual_copy(s, t, name.as_deref())? {
|
||||
made.push(id);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +165,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let op = s.catalog.set_top_ops(id);
|
||||
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
|
||||
let op = s.catalog.move_in_stack_ops(id, -1);
|
||||
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
|
||||
let op = s.catalog.move_in_stack_ops(id, 1);
|
||||
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!(
|
||||
"stack.split",
|
||||
"Split Stack",
|
||||
["Photo", "Stack"],
|
||||
None,
|
||||
"{id?} — this photo and the ones after it become their own stack",
|
||||
in_stack,
|
||||
|s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
|
||||
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
|
||||
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
|
||||
}
|
||||
),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p));
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
|
||||
@@ -48,6 +48,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = i.get("creator").and_then(Value::as_str) {
|
||||
d.creator = v.trim().to_string();
|
||||
}
|
||||
match i.get("metadataPreset") {
|
||||
Some(Value::String(n)) if n.is_empty() || n == "none" => d.metadata_preset = None,
|
||||
Some(Value::String(n)) => {
|
||||
if !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad(ID, format!("unknown metadata preset `{n}` (see metadata.presets)")));
|
||||
}
|
||||
d.metadata_preset = Some(n.clone());
|
||||
}
|
||||
Some(Value::Null) => d.metadata_preset = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
|
||||
d.cameras.clear();
|
||||
for c in a {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
|
||||
use crate::preset_import::{group_from_dir, read_presets};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
@@ -12,23 +13,36 @@ fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
|
||||
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()).map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut imported = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
for f in expand_preset_paths(&paths) {
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
let n = list.len();
|
||||
let ids = s.add_presets(list);
|
||||
skipped += n - ids.len();
|
||||
for id in ids {
|
||||
if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
|
||||
imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
|
||||
for top in &paths {
|
||||
let from_dir = std::path::Path::new(top).is_dir();
|
||||
for f in expand_preset_paths(std::slice::from_ref(top)) {
|
||||
// presets in a folder go to a group named after it (unless the file names its own)
|
||||
// the path from the imported folder (inclusive) to the file's folder
|
||||
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
|
||||
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
|
||||
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
for mut it in list {
|
||||
if let Some(g) = &group {
|
||||
it.preset.group = g.clone();
|
||||
}
|
||||
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
|
||||
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
|
||||
match id {
|
||||
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
|
||||
@@ -69,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], skipped, failed}",
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
|
||||
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
|
||||
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
|
||||
//! with `wait` (CLI, MCP, tests).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::media::{RenderJob, THUMB_SIZES};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The long edge of a standard-sized preview.
|
||||
pub const STANDARD_EDGE: usize = 2048;
|
||||
|
||||
/// A preview build in progress.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PreviewBuild {
|
||||
pub total: usize,
|
||||
pub done: AtomicUsize,
|
||||
pub failed: AtomicUsize,
|
||||
pub cancel: AtomicBool,
|
||||
pub finished: AtomicBool,
|
||||
}
|
||||
|
||||
impl PreviewBuild {
|
||||
pub fn json(&self) -> Value {
|
||||
json!({
|
||||
"total": self.total,
|
||||
"done": self.done.load(Ordering::Relaxed),
|
||||
"failed": self.failed.load(Ordering::Relaxed),
|
||||
"running": !self.finished.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
/// `edge` (`None` = 1:1, the photo's full size).
|
||||
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
|
||||
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
|
||||
let full = p.width.max(p.height) as usize;
|
||||
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
|
||||
let mut v = Vec::new();
|
||||
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
|
||||
v.extend(s.loupe_job(id, e, e, true));
|
||||
v
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
|
||||
for photo_jobs in jobs {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
state.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
state.finished.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.buildPreviews";
|
||||
let edge = match str_param(p, "size").unwrap_or("standard") {
|
||||
"standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)),
|
||||
"full" | "1:1" => None,
|
||||
other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),
|
||||
};
|
||||
if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
// explicit ids, else the selection, else everything in view
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
|
||||
if wait {
|
||||
run_all(jobs, &state);
|
||||
return Ok(state.json());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-build-previews".into())
|
||||
.spawn(move || run_all(jobs, &st))
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
}
|
||||
Ok(state.json())
|
||||
}
|
||||
|
||||
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
|
||||
/// photos editable while their originals are offline.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviews";
|
||||
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if path.exists() {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string()));
|
||||
match r {
|
||||
Ok(()) => built += 1,
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
}
|
||||
Ok(json!({"built": built, "removed": removed, "failed": failed}))
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
(Some(dir), Some(p)) => dir.join(crate::smart::file_name(p)).exists(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cmd!(
|
||||
"library.smartPreviews",
|
||||
"Build Smart Previews",
|
||||
[],
|
||||
None,
|
||||
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline → {built, removed, failed}",
|
||||
always,
|
||||
smart
|
||||
),
|
||||
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()) {
|
||||
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
|
||||
_ => true,
|
||||
};
|
||||
Ok(json!({"smartPreview": has_smart_preview(s, id), "originalOnline": online}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.buildPreviews",
|
||||
"Build Previews",
|
||||
[],
|
||||
None,
|
||||
"{size?: standard (2048 px, or `edge`) | full (1:1), ids?, wait?: bool} — render the grid thumbnail and loupe view of the selected photos (else all in view) into the preview cache, in the background unless `wait` → {total, done, failed, running}",
|
||||
always,
|
||||
build
|
||||
),
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
|
||||
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
|
||||
}),
|
||||
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
|
||||
if let Some(b) = &s.preview_build {
|
||||
b.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
|
||||
"deleted": p.deleted,
|
||||
"copyOf": p.copy_of.map(|c| c.0),
|
||||
"copyName": p.copy_name,
|
||||
// an undecodable raw variant, shown and edited from its embedded JPEG: why
|
||||
"previewOnly": p.preview_only,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,6 +97,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"clipboard": s.clipboard.is_some(),
|
||||
}))
|
||||
}),
|
||||
cmd!(query "photo.allMetadata", "All Metadata", [], None, "{id?} → {exif: [{group, tag, name, value}], xmp: [{name, value}]} — every EXIF / TIFF / GPS tag of the file and its XMP properties", always, |s, p| {
|
||||
let id = photo_arg(s, p, "photo.allMetadata")?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| super::bad("photo.allMetadata", "no such photo"))?.clone();
|
||||
let lightcraft_catalog::Source::File { path } = &ph.source else {
|
||||
return Ok(json!({"exif": [], "xmp": [], "note": "a generated demo photo has no file"}));
|
||||
};
|
||||
let bytes = match &s.media.file_bytes {
|
||||
Some(r) => r(path),
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}")),
|
||||
}
|
||||
.map_err(|e| super::bad("photo.allMetadata", e))?;
|
||||
let exif: Vec<Value> = lightcraft_meta::file_tag_rows(&bytes)
|
||||
.into_iter()
|
||||
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
|
||||
.collect();
|
||||
// XMP: the sidecar if there is one, else the file's own packet
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.xmp.naming).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let xmp: Vec<Value> = packet
|
||||
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
|
||||
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(json!({"exif": exif, "xmp": xmp}))
|
||||
}),
|
||||
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
|
||||
let all: Vec<Album> = s.catalog.albums().cloned().collect();
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
|
||||
|
||||
@@ -55,7 +55,21 @@ pub fn has_adjustments(props: &Props) -> bool {
|
||||
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
|
||||
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn note(k: &str) {
|
||||
READ.with(|r| {
|
||||
if let Some(s) = r.borrow_mut().as_mut() {
|
||||
s.insert(k.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
|
||||
note(k);
|
||||
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
@@ -95,6 +109,7 @@ pub fn rel_to_kelvin(r: f64) -> f64 {
|
||||
|
||||
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
|
||||
fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
note(k);
|
||||
let items = props.get(k)?;
|
||||
let mut pts = Vec::new();
|
||||
for it in items {
|
||||
@@ -130,9 +145,30 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
n(o, "Shadows2012", "light.shadows");
|
||||
n(o, "Whites2012", "light.whites");
|
||||
n(o, "Blacks2012", "light.blacks");
|
||||
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
|
||||
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
|
||||
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
|
||||
// presets written by newer versions keep the old fields at their defaults: ignore them there
|
||||
let pv2012 = props.keys().any(|k| k.ends_with("2012"));
|
||||
let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| {
|
||||
let v = num(props, &format!("crs:{crs}"));
|
||||
if !pv2012
|
||||
&& !has(o, path)
|
||||
&& let Some(v) = v
|
||||
{
|
||||
put(o, path, json!(f(v).clamp(-100.0, 100.0)));
|
||||
}
|
||||
};
|
||||
old(o, "Exposure", "light.exposure", &|v| v);
|
||||
old(o, "Contrast", "light.contrast", &|v| v - 25.0);
|
||||
old(o, "HighlightRecovery", "light.highlights", &|v| -v);
|
||||
old(o, "FillLight", "light.shadows", &|v| v);
|
||||
old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0);
|
||||
old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6);
|
||||
// ---- Presence
|
||||
n(o, "Texture", "effects.texture");
|
||||
n(o, "Clarity2012", "effects.clarity");
|
||||
old(o, "Clarity", "effects.clarity", &|v| v);
|
||||
n(o, "Dehaze", "effects.dehaze");
|
||||
n(o, "Vibrance", "color.vibrance");
|
||||
n(o, "Saturation", "color.saturation");
|
||||
@@ -191,6 +227,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
n(o, "ParametricMidtoneSplit", "curve.split_mid");
|
||||
n(o, "ParametricHighlightSplit", "curve.split_highlights");
|
||||
n(o, "CurveRefineSaturation", "curve.refine_saturation");
|
||||
if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) {
|
||||
// the older single curve, used when no 2012 curve is present
|
||||
if !props.contains_key("crs:ToneCurvePV2012") {
|
||||
put(o, "curve.master", c);
|
||||
}
|
||||
}
|
||||
for (crs, ch) in
|
||||
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
|
||||
{
|
||||
@@ -310,6 +352,42 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
out
|
||||
}
|
||||
|
||||
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
|
||||
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
|
||||
///
|
||||
/// With `values` (the packet's structured properties), local corrections become masks too
|
||||
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
|
||||
/// can't be carried over are reported as `Mask: <kind>`.
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
|
||||
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
|
||||
let mut out = to_partial(props, raw);
|
||||
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
let mut mask_skips = Vec::new();
|
||||
if let Some(values) = values {
|
||||
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
|
||||
if !masks.is_empty() {
|
||||
put(&mut out, "masks", Value::Array(masks));
|
||||
}
|
||||
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
|
||||
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves
|
||||
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
|
||||
let mut unmapped: Vec<String> = props
|
||||
.keys()
|
||||
.filter(|k| k.starts_with("crs:"))
|
||||
.map(|k| k.split('/').next().unwrap_or(k))
|
||||
.filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k))
|
||||
.map(|k| k.trim_start_matches("crs:").to_string())
|
||||
.filter(|k| !quiet(k))
|
||||
.collect();
|
||||
unmapped.dedup();
|
||||
unmapped.extend(mask_skips);
|
||||
(out, unmapped)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Interchange: local corrections (masks) stored as `crs:` structures in XMP sidecars, XMP
|
||||
//! presets and `.lrtemplate` files → our [`Mask`](lightcraft_develop::Mask)s.
|
||||
//!
|
||||
//! Four containers hold them: `MaskGroupBasedCorrections` (current: each correction has a list
|
||||
//! of mask components) and the older `GradientBasedCorrections`, `CircularGradientBasedCorrections`
|
||||
//! and `PaintBasedCorrections`. Every correction carries its local sliders (`Local*`, stored as
|
||||
//! −1..1 fractions; exposure ×4 EV) and its components (`crs:What` = `Mask/Gradient`,
|
||||
//! `Mask/CircularGradient`, `Mask/Paint`, `Mask/Image`, `Mask/RangeMask`).
|
||||
//!
|
||||
//! Implemented from the public XMP data model and black-box observation; best effort — the
|
||||
//! pipelines differ, and the component combine modes and AI mask kinds are mapped from what the
|
||||
//! fields evidently mean. Unsupported components are reported, never guessed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use lightcraft_meta::XmpValue;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Top-level properties keyed `prefix:name`, with structure.
|
||||
pub type Values = BTreeMap<String, XmpValue>;
|
||||
|
||||
/// The containers this module reads.
|
||||
pub const CONTAINERS: [&str; 4] =
|
||||
["crs:MaskGroupBasedCorrections", "crs:GradientBasedCorrections", "crs:CircularGradientBasedCorrections", "crs:PaintBasedCorrections"];
|
||||
|
||||
fn text<'a>(v: &'a XmpValue, k: &str) -> Option<&'a str> {
|
||||
v.field(k).and_then(XmpValue::text).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn num(v: &XmpValue, k: &str) -> Option<f64> {
|
||||
let s = text(v, k)?;
|
||||
let s = s.strip_prefix('+').unwrap_or(s);
|
||||
s.parse::<f64>().ok().filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
fn flag(v: &XmpValue, k: &str) -> Option<bool> {
|
||||
text(v, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
|
||||
}
|
||||
|
||||
/// Local slider fields → (our `adjust` field, scale from the stored fraction).
|
||||
const LOCAL: &[(&str, &str, f64)] = &[
|
||||
("LocalExposure2012", "exposure", 4.0),
|
||||
("LocalContrast2012", "contrast", 100.0),
|
||||
("LocalHighlights2012", "highlights", 100.0),
|
||||
("LocalShadows2012", "shadows", 100.0),
|
||||
("LocalWhites2012", "whites", 100.0),
|
||||
("LocalBlacks2012", "blacks", 100.0),
|
||||
("LocalClarity2012", "clarity", 100.0),
|
||||
("LocalTexture", "texture", 100.0),
|
||||
("LocalDehaze", "dehaze", 100.0),
|
||||
("LocalTemperature", "temp", 100.0),
|
||||
("LocalTint", "tint", 100.0),
|
||||
("LocalSaturation", "saturation", 100.0),
|
||||
("LocalHue", "hue", 100.0),
|
||||
("LocalSharpness", "sharpness", 100.0),
|
||||
("LocalLuminanceNoise", "noise", 100.0),
|
||||
("LocalMoire", "moire", 100.0),
|
||||
("LocalDefringe", "defringe", 100.0),
|
||||
("LocalToningSaturation", "color_sat", 100.0),
|
||||
("LocalToningHue", "color_hue", 1.0),
|
||||
// older process versions
|
||||
("LocalExposure", "exposure", 4.0),
|
||||
("LocalContrast", "contrast", 100.0),
|
||||
("LocalClarity", "clarity", 100.0),
|
||||
];
|
||||
|
||||
fn adjust(c: &XmpValue) -> Value {
|
||||
let mut a = serde_json::Map::new();
|
||||
for (crs, ours, k) in LOCAL {
|
||||
if a.contains_key(*ours) {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = num(c, &format!("crs:{crs}")) {
|
||||
let lim = if *ours == "exposure" {
|
||||
4.0
|
||||
} else if *ours == "color_hue" {
|
||||
360.0
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
let v = (v * k).clamp(if *ours == "color_hue" { 0.0 } else { -lim }, lim);
|
||||
a.insert((*ours).into(), json!(v));
|
||||
}
|
||||
}
|
||||
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
|
||||
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
|
||||
Value::Object(a)
|
||||
}
|
||||
|
||||
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
|
||||
/// luminance on −8..+4 EV mapped to 0..1.
|
||||
fn lightness_to_range(l: f64) -> f64 {
|
||||
let y = l.clamp(1e-4, 1.0).powf(2.2);
|
||||
((y.log2() + 8.0) / 12.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Value {
|
||||
json!({"x": x, "y": y})
|
||||
}
|
||||
|
||||
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
|
||||
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
|
||||
let what = text(m, "crs:What").unwrap_or("");
|
||||
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
|
||||
1 => "subtract",
|
||||
2 => "intersect",
|
||||
_ => "add",
|
||||
};
|
||||
let invert = flag(m, "crs:MaskInverted").unwrap_or(false);
|
||||
// positions are fractions of the image's width / height; our radii are in long-edge units
|
||||
let (sx, sy) = if aspect >= 1.0 { (1.0, 1.0 / aspect) } else { (aspect, 1.0) };
|
||||
let shape = match what {
|
||||
"Mask/Gradient" => {
|
||||
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
|
||||
json!({"kind": "linear", "start": pt(g("FullX", 0.5), g("FullY", 0.0)), "end": pt(g("ZeroX", 0.5), g("ZeroY", 1.0))})
|
||||
}
|
||||
"Mask/CircularGradient" => {
|
||||
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
|
||||
let (l, r, t, b) = (g("Left", 0.25), g("Right", 0.75), g("Top", 0.25), g("Bottom", 0.75));
|
||||
json!({
|
||||
"kind": "radial",
|
||||
"center": pt((l + r) / 2.0, (t + b) / 2.0),
|
||||
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
|
||||
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
|
||||
"angle": g("Angle", 0.0),
|
||||
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
|
||||
"invert": flag(m, "crs:Flipped").unwrap_or(false),
|
||||
})
|
||||
}
|
||||
"Mask/Paint" => {
|
||||
let pts: Vec<Value> = m
|
||||
.field("crs:Dabs")
|
||||
.map(XmpValue::items)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(XmpValue::text)
|
||||
.filter_map(|d| {
|
||||
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
|
||||
Some(pt(it.next()?, it.next()?))
|
||||
})
|
||||
.collect();
|
||||
if pts.is_empty() {
|
||||
return Err("empty brush".into());
|
||||
}
|
||||
let erase = num(m, "crs:MaskValue").is_some_and(|v| v <= 0.0);
|
||||
json!({"kind": "brush", "strokes": [{
|
||||
"points": pts,
|
||||
"size": num(m, "crs:Radius").unwrap_or(0.03).clamp(0.001, 1.0),
|
||||
"feather": (num(m, "crs:CenterWeight").unwrap_or(0.5) * 100.0).clamp(0.0, 100.0),
|
||||
"flow": (num(m, "crs:Flow").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0),
|
||||
"density": (num(m, "crs:MaskValue").unwrap_or(1.0).abs() * 100.0).clamp(0.0, 100.0),
|
||||
"erase": erase,
|
||||
}]})
|
||||
}
|
||||
"Mask/Image" => match text(m, "crs:MaskSubType") {
|
||||
Some("1") => json!({"kind": "subject"}),
|
||||
Some("2") => json!({"kind": "sky"}),
|
||||
other => return Err(format!("AI mask ({})", other.unwrap_or("?"))),
|
||||
},
|
||||
"Mask/RangeMask" => {
|
||||
let r = m.field("crs:CorrectionRangeMask").unwrap_or(m);
|
||||
match text(r, "crs:Type") {
|
||||
Some("2") => {
|
||||
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
|
||||
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
|
||||
}
|
||||
Some("3") => {
|
||||
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, _] = q[..] else { return Err("depth range".into()) };
|
||||
json!({"kind": "depthRange", "lo": b, "hi": c, "feather": (b - a).max(0.0)})
|
||||
}
|
||||
_ => return Err("colour range".into()),
|
||||
}
|
||||
}
|
||||
other => return Err(if other.is_empty() { "unknown mask".into() } else { other.trim_start_matches("Mask/").to_string() }),
|
||||
};
|
||||
Ok(json!({"op": op, "invert": invert, "shape": shape}))
|
||||
}
|
||||
|
||||
/// Masks in `values` (JSON for `DevelopSettings::masks`), plus the kinds of component that
|
||||
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
|
||||
pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for container in CONTAINERS {
|
||||
let Some(list) = values.get(container) else { continue };
|
||||
for c in list.items() {
|
||||
if flag(c, "crs:CorrectionActive") == Some(false) {
|
||||
continue;
|
||||
}
|
||||
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
|
||||
let mut parts = Vec::new();
|
||||
for m in comps {
|
||||
match component(m, aspect) {
|
||||
Ok(v) => parts.push(v),
|
||||
Err(kind) => skipped.push(kind),
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// brush dabs of one correction are one brush component
|
||||
let mut merged: Vec<Value> = Vec::new();
|
||||
for p in parts {
|
||||
let is_brush = p["shape"]["kind"] == "brush";
|
||||
if is_brush
|
||||
&& let Some(prev) = merged.iter_mut().find(|q| q["shape"]["kind"] == "brush" && q["op"] == p["op"])
|
||||
&& let (Some(a), Some(b)) = (prev["shape"]["strokes"].as_array_mut(), p["shape"]["strokes"].as_array())
|
||||
{
|
||||
a.extend(b.iter().cloned());
|
||||
continue;
|
||||
}
|
||||
merged.push(p);
|
||||
}
|
||||
let n = out.len() + 1;
|
||||
let name = text(c, "crs:CorrectionName").map(str::to_string).unwrap_or_else(|| format!("Mask {n}"));
|
||||
out.push(json!({"id": n, "name": name, "visible": true, "invert": false, "components": merged, "adjust": adjust(c)}));
|
||||
}
|
||||
}
|
||||
skipped.sort();
|
||||
skipped.dedup();
|
||||
(out, skipped)
|
||||
}
|
||||
|
||||
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
|
||||
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
|
||||
use crate::preset_import::Lua;
|
||||
match v {
|
||||
Lua::Nil => XmpValue::Text(String::new()),
|
||||
Lua::Bool(b) => XmpValue::Text(if *b { "true" } else { "false" }.into()),
|
||||
Lua::Num(n) => XmpValue::Text(format!("{n}")),
|
||||
Lua::Str(s) => XmpValue::Text(s.clone()),
|
||||
Lua::Table(arr, map) if map.is_empty() => XmpValue::Array(arr.iter().map(from_lua).collect()),
|
||||
Lua::Table(_, map) => XmpValue::Struct(map.iter().map(|(k, v)| (format!("crs:{k}"), from_lua(v))).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The aspect radial masks are computed for when the target's shape isn't known yet.
|
||||
pub const DEFAULT_ASPECT: f64 = 1.5;
|
||||
|
||||
/// Re-fit radial masks in `partial` (computed for aspect `from`) to a target of aspect `to`.
|
||||
pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
|
||||
let scale = |a: f64| if a >= 1.0 { (1.0, 1.0 / a) } else { (a, 1.0) };
|
||||
let ((fx, fy), (tx, ty)) = (scale(from), scale(to));
|
||||
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
|
||||
for m in masks {
|
||||
for c in m["components"].as_array_mut().into_iter().flatten() {
|
||||
let s = &mut c["shape"];
|
||||
if s["kind"] == "radial" {
|
||||
if let Some(rx) = s["rx"].as_f64() {
|
||||
s["rx"] = json!(rx / fx * tx);
|
||||
}
|
||||
if let Some(ry) = s["ry"].as_f64() {
|
||||
s["ry"] = json!(ry / fy * ty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
|
||||
|
||||
/// A packet in the shape local corrections take (values written for this test).
|
||||
const PACKET: &str = 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.2">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
|
||||
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
|
||||
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
|
||||
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
|
||||
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
|
||||
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
<crs:PaintBasedCorrections><rdf:Seq>
|
||||
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
|
||||
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
|
||||
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
|
||||
</rdf:Seq></crs:CorrectionMasks></rdf:li>
|
||||
</rdf:Seq></crs:PaintBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
fn settings() -> (DevelopSettings, Vec<String>) {
|
||||
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
|
||||
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrections_become_masks() {
|
||||
let (s, unmapped) = settings();
|
||||
assert_eq!(s.light.exposure, 0.2, "global settings still map");
|
||||
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
|
||||
let sky = &s.masks[0];
|
||||
assert_eq!(sky.name, "Darker sky");
|
||||
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
|
||||
assert_eq!(sky.components[0].shape, MaskShape::Sky);
|
||||
assert_eq!(sky.components[1].op, MaskOp::Intersect);
|
||||
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
|
||||
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
|
||||
let rad = &s.masks[1];
|
||||
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
|
||||
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
|
||||
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
|
||||
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
|
||||
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
|
||||
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
|
||||
assert!(lo < hi && *lo_feather > 0.0);
|
||||
let brush = &s.masks[2];
|
||||
assert_eq!(brush.adjust.clarity, 40.0);
|
||||
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
|
||||
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
|
||||
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
|
||||
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_render() {
|
||||
let (s, _) = settings();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
|
||||
let render = |d: &DevelopSettings| {
|
||||
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
|
||||
};
|
||||
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_corrections() {
|
||||
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
|
||||
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
|
||||
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
|
||||
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
|
||||
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
|
||||
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
|
||||
assert_eq!(s.masks.len(), 1);
|
||||
assert_eq!(s.masks[0].adjust.exposure, -2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_add_masks_once() {
|
||||
let (s, _) = settings();
|
||||
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
|
||||
let p =
|
||||
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
|
||||
let mut mine = DevelopSettings::default();
|
||||
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
|
||||
let once = p.apply(&mine, 1.0);
|
||||
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
|
||||
assert_eq!(once.masks[1].id, 8, "fresh ids");
|
||||
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
|
||||
let half = p.apply(&mine, 0.5);
|
||||
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_radials_fit_the_photo() {
|
||||
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
|
||||
refit_radials(&mut v, 1.5, 1.0);
|
||||
let s = &v["masks"][0]["components"][0]["shape"];
|
||||
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
|
||||
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ pub fn load(s: &mut Session) {
|
||||
}
|
||||
let folder = s.catalog.alloc_album_id();
|
||||
ops.push(Op::AddAlbum {
|
||||
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None },
|
||||
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
});
|
||||
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
|
||||
for (name, parent, photos) in [
|
||||
@@ -75,7 +75,7 @@ pub fn load(s: &mut Session) {
|
||||
] {
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } });
|
||||
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } });
|
||||
}
|
||||
for op in ops {
|
||||
let _ = s.catalog.apply(op);
|
||||
|
||||
+145
-10
@@ -213,11 +213,29 @@ pub struct Watermark {
|
||||
pub color: [u8; 3],
|
||||
/// Soft dark drop shadow for legibility on bright areas.
|
||||
pub shadow: bool,
|
||||
/// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text).
|
||||
pub image: String,
|
||||
/// The graphic's width as a fraction of the photo's width.
|
||||
pub image_width: f32,
|
||||
/// The colour space the photo is in (set by the encoder; the graphic is converted into it).
|
||||
#[serde(skip)]
|
||||
pub target: Option<OutputSpace>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
|
||||
Self {
|
||||
text: String::new(),
|
||||
size: 0.035,
|
||||
opacity: 0.7,
|
||||
anchor: Anchor::BottomRight,
|
||||
inset: 0.025,
|
||||
color: [255; 3],
|
||||
shadow: true,
|
||||
image: String::new(),
|
||||
image_width: 0.2,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +279,10 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
|
||||
/// every covered pixel (shadow pass first).
|
||||
fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
|
||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
|
||||
if !wm.image.trim().is_empty() {
|
||||
logo_coverage(width, height, wm, blend_px);
|
||||
return;
|
||||
}
|
||||
let text = wm.text.trim();
|
||||
if text.is_empty() || width == 0 || height == 0 {
|
||||
return;
|
||||
@@ -315,6 +337,74 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px:
|
||||
}
|
||||
}
|
||||
|
||||
/// A watermark graphic, decoded once per file (and again when the file changes).
|
||||
fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None);
|
||||
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let map = c.get_or_insert_with(HashMap::new);
|
||||
if let Some((m, img)) = map.get(path)
|
||||
&& *m == modified
|
||||
{
|
||||
return Some(img.clone());
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8());
|
||||
map.insert(path.to_string(), (modified, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
|
||||
/// `image_width` of the photo, placed like the text, blended with its alpha × opacity.
|
||||
fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) {
|
||||
let Some(logo) = watermark_logo(wm.image.trim()) else {
|
||||
log::warn!("watermark: can't read {}", wm.image);
|
||||
return;
|
||||
};
|
||||
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
|
||||
return;
|
||||
}
|
||||
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
|
||||
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
|
||||
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
|
||||
let p = logo.get(x, y);
|
||||
let a = p[3] as f32 / 255.0;
|
||||
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
|
||||
});
|
||||
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
|
||||
let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32;
|
||||
let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32);
|
||||
use Anchor::*;
|
||||
let ox = match wm.anchor {
|
||||
TopLeft | Left | BottomLeft => inset,
|
||||
Top | Center | Bottom => (w - lwf) / 2.0,
|
||||
TopRight | Right | BottomRight => w - inset - lwf,
|
||||
}
|
||||
.max(0.0) as usize;
|
||||
let oy = match wm.anchor {
|
||||
TopLeft | Top | TopRight => inset,
|
||||
Left | Center | Right => (h - lhf) / 2.0,
|
||||
BottomLeft | Bottom | BottomRight => h - inset - lhf,
|
||||
}
|
||||
.max(0.0) as usize;
|
||||
let space = wm.target.unwrap_or(OutputSpace::Srgb);
|
||||
let op = wm.opacity.clamp(0.0, 1.0);
|
||||
for y in 0..lh {
|
||||
for x in 0..lw {
|
||||
let [r, g, b, a] = scaled.get(x, y);
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
let (px, py) = (ox + x, oy + y);
|
||||
if a <= 1e-4 || px >= width || py >= height {
|
||||
continue;
|
||||
}
|
||||
let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8);
|
||||
blend_px(px, py, a * op, srgb8_in(space, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which metadata is embedded in exported files.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -355,6 +445,8 @@ pub struct ExportOptions {
|
||||
pub conflict: Conflict,
|
||||
/// TIFF compression.
|
||||
pub tiff_compression: TiffCompression,
|
||||
/// DNG export: how the raw data is stored.
|
||||
pub dng_compression: DngCompression,
|
||||
pub metadata: MetadataPolicy,
|
||||
/// Strip GPS / location even when the policy would include it.
|
||||
pub remove_location: bool,
|
||||
@@ -382,6 +474,7 @@ impl Default for ExportOptions {
|
||||
subfolder: String::new(),
|
||||
conflict: Conflict::Unique,
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
dng_compression: DngCompression::Lossless,
|
||||
metadata: MetadataPolicy::All,
|
||||
remove_location: false,
|
||||
watermark: None,
|
||||
@@ -423,6 +516,7 @@ impl ExportOptions {
|
||||
Some("zip" | "deflate") => TiffCompression::Deflate,
|
||||
_ => d.tiff_compression,
|
||||
},
|
||||
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
|
||||
metadata: enm(p, "metadata").unwrap_or(d.metadata),
|
||||
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
|
||||
watermark: match p.get("watermark") {
|
||||
@@ -430,7 +524,7 @@ impl ExportOptions {
|
||||
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty()),
|
||||
.filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()),
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
}
|
||||
@@ -648,7 +742,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let space = o.effective_space();
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() };
|
||||
let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
}
|
||||
let profile = icc::write_named(named_space(space));
|
||||
@@ -732,7 +826,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() };
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };
|
||||
draw_watermark_deep(&mut img, &wm);
|
||||
}
|
||||
let profile = match img.samples {
|
||||
@@ -854,7 +948,7 @@ enum Work {
|
||||
path: String,
|
||||
read: Option<crate::merge::ByteReader>,
|
||||
packet: String,
|
||||
dng: bool,
|
||||
dng: Option<DngCompression>,
|
||||
label: String,
|
||||
size: (usize, usize),
|
||||
},
|
||||
@@ -881,8 +975,8 @@ pub fn prepare_export(
|
||||
Work::File {
|
||||
path: path.clone(),
|
||||
read: session.media.file_bytes.clone(),
|
||||
packet: crate::sidecar::sidecar_packet(p),
|
||||
dng: o.format == ExportFormat::Dng,
|
||||
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
|
||||
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
|
||||
label: p.file_name.clone(),
|
||||
size: (p.width as usize, p.height as usize),
|
||||
}
|
||||
@@ -905,15 +999,20 @@ impl PreparedExport {
|
||||
Some(r) => r(&path)?,
|
||||
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
if !dng {
|
||||
let Some(dng) = dng else {
|
||||
return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] });
|
||||
}
|
||||
};
|
||||
if lightcraft_raw::probe(&bytes).is_none() {
|
||||
return Err(format!("{label}: DNG export needs a raw photo"));
|
||||
}
|
||||
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() })
|
||||
let compression = match dng {
|
||||
DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 },
|
||||
DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false },
|
||||
DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed,
|
||||
};
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() })
|
||||
}
|
||||
@@ -921,6 +1020,18 @@ impl PreparedExport {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an exported DNG stores its raw data.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DngCompression {
|
||||
/// Lossless JPEG (the usual DNG compression).
|
||||
#[default]
|
||||
Lossless,
|
||||
/// Deflate (zip) tiles with prediction.
|
||||
Deflate,
|
||||
Uncompressed,
|
||||
}
|
||||
|
||||
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
|
||||
/// applied), or one exact file path (single photo; overwritten).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -1108,6 +1219,30 @@ mod tests {
|
||||
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_watermark_is_placed_scaled_and_blended() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let logo_path = dir.join("logo.png");
|
||||
// 40×20: left half opaque red, right half fully transparent
|
||||
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] });
|
||||
let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap();
|
||||
std::fs::write(&logo_path, png).unwrap();
|
||||
let mut img = Rgba8::new(400, 300);
|
||||
img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]);
|
||||
let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() };
|
||||
draw_watermark(&mut img, &wm);
|
||||
// 100 px wide × 50 px high in the bottom-right corner
|
||||
assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280));
|
||||
assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo");
|
||||
assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo");
|
||||
assert_eq!(img.get(10, 10), [0, 0, 0, 255]);
|
||||
// an image-only watermark survives the params (no text needed)
|
||||
let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}}));
|
||||
assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_and_params() {
|
||||
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
|
||||
|
||||
@@ -114,6 +114,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: Some(why),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -141,6 +142,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||||
preview_only: None,
|
||||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
@@ -170,6 +172,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
content_hash,
|
||||
embedded_lens: None,
|
||||
xmp: None,
|
||||
preview_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,6 +399,62 @@ mod tests {
|
||||
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
|
||||
}
|
||||
|
||||
/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's
|
||||
/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a
|
||||
/// library reopen, and Reload / Relink clear it once the file decodes (undoably).
|
||||
#[test]
|
||||
fn unsupported_raw_imports_as_preview_only_and_survives_reopen() {
|
||||
use serde_json::json;
|
||||
let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (lib, files) = (dir.join("lib"), dir.join("files"));
|
||||
std::fs::create_dir_all(&files).unwrap();
|
||||
let path = files.join("DSC_0001.cr3");
|
||||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// the import review already says so
|
||||
let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}");
|
||||
let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
let why = p.preview_only.clone().expect("marked preview only");
|
||||
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
|
||||
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
|
||||
assert!(!p.develops_raw());
|
||||
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
|
||||
assert_eq!(crate::media::source_info(&p), SourceInfo::default());
|
||||
assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default());
|
||||
assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok());
|
||||
// agents see it
|
||||
let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap();
|
||||
assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}");
|
||||
let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(i["preview_only"], json!(why), "{i}");
|
||||
// a library reopen keeps it
|
||||
s.persist().unwrap();
|
||||
drop(s);
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
|
||||
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
|
||||
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
|
||||
assert!(s.catalog.photo(id).unwrap().develops_raw());
|
||||
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
|
||||
// relinking to a file that decodes clears it too
|
||||
let dng = files.join("DSC_0001.dng");
|
||||
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||||
s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().preview_only, None);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_jobs_show_embedded_previews_then_cached_renders() {
|
||||
use crate::media::QuickSource;
|
||||
|
||||
+214
-43
@@ -52,6 +52,41 @@ pub struct ImportOptions {
|
||||
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
|
||||
/// same content as one already known is still listed.
|
||||
pub local: bool,
|
||||
/// Copy: where the copies go (default: the library's `Originals/`).
|
||||
pub destination: Option<String>,
|
||||
/// Copy: the folders inside the destination.
|
||||
pub organize: Organize,
|
||||
/// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`,
|
||||
/// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`.
|
||||
pub rename: Option<String>,
|
||||
pub rename_start: usize,
|
||||
/// A metadata preset (by name) applied to every imported photo.
|
||||
pub metadata_preset: Option<String>,
|
||||
/// Copy: raws are copied as DNG (Copy as DNG).
|
||||
pub convert_dng: bool,
|
||||
}
|
||||
|
||||
/// How copies are filed in the destination.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Organize {
|
||||
/// `YYYY/YYYY-MM-DD/` by capture date.
|
||||
#[default]
|
||||
ByDay,
|
||||
/// `YYYY/YYYY-MM/`.
|
||||
ByMonth,
|
||||
/// All in the destination itself.
|
||||
Flat,
|
||||
}
|
||||
|
||||
impl Organize {
|
||||
pub fn parse(s: &str) -> Option<Organize> {
|
||||
match s {
|
||||
"date" | "day" | "byDay" => Some(Organize::ByDay),
|
||||
"month" | "byMonth" => Some(Organize::ByMonth),
|
||||
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file found by [`scan`], for the import review.
|
||||
@@ -73,6 +108,9 @@ pub struct ImportCandidate {
|
||||
pub existing: Option<u64>,
|
||||
/// Not readable.
|
||||
pub error: Option<String>,
|
||||
/// A raw variant that can't be decoded yet (why): it imports as preview only.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -127,6 +165,14 @@ pub struct ImportDefaults {
|
||||
pub copyright: String,
|
||||
/// Creator given to imported photos that don't name one (empty = none).
|
||||
pub creator: String,
|
||||
/// Metadata preset applied to every imported photo (`metadata.*`; `None` = none).
|
||||
pub metadata_preset: Option<String>,
|
||||
/// Auto import: a watched folder whose new photos are added as they arrive (`None` = off),
|
||||
/// copied into the library's Originals instead of added in place when `auto_copy`, and an
|
||||
/// album they go to.
|
||||
pub auto_folder: Option<String>,
|
||||
pub auto_copy: bool,
|
||||
pub auto_album: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -164,8 +210,12 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
|
||||
if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() {
|
||||
p.meta.creator = d.creator.trim().to_string();
|
||||
}
|
||||
if let Some(mp) = d.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
let base = p.camera_defaults();
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
// a raw shown from its embedded JPEG gets the rendered-file default, not the raw one
|
||||
let raw = p.develops_raw();
|
||||
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) => {
|
||||
@@ -213,8 +263,14 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
let Some(probe) = s.media.file_probe.clone() else {
|
||||
/// 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())
|
||||
}
|
||||
|
||||
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let Some(probe) = probe.cloned() else {
|
||||
return paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -240,7 +296,11 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
if i >= paths.len() {
|
||||
break;
|
||||
}
|
||||
if progress.cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
let r = probe(&paths[i]);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
|
||||
}
|
||||
});
|
||||
@@ -249,16 +309,33 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect();
|
||||
}
|
||||
}
|
||||
paths.iter().map(|p| probe(p)).collect()
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if progress.cancel.load(Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let r = probe(p);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
r
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
|
||||
fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String> {
|
||||
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
|
||||
/// made unique with -1, -2…; returns the new path.
|
||||
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
let dir = lib.join("Originals").join(year).join(day);
|
||||
let dir = match organize {
|
||||
Organize::ByDay => root.join(year).join(day),
|
||||
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
|
||||
Organize::Flat => root.to_path_buf(),
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) => (s.to_string(), format!(".{e}")),
|
||||
None => (name.clone(), String::new()),
|
||||
@@ -273,57 +350,112 @@ fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String
|
||||
Ok(dst.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
/// share can take minutes to read).
|
||||
pub struct ScanInput {
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
skip: Option<PathBuf>,
|
||||
/// path → (photo, its summary) for files already in the library.
|
||||
by_path: HashMap<String, (PhotoId, ImportCandidate)>,
|
||||
by_hash: HashMap<String, PhotoId>,
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
|
||||
/// Progress and cancellation of a running [`scan_with`].
|
||||
#[derive(Default)]
|
||||
pub struct ScanProgress {
|
||||
/// Files to probe, set once the folders are expanded.
|
||||
pub total: std::sync::atomic::AtomicUsize,
|
||||
pub done: std::sync::atomic::AtomicUsize,
|
||||
pub cancel: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
|
||||
pub struct ScanOutput {
|
||||
pub candidates: Vec<ImportCandidate>,
|
||||
pub probes: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
|
||||
impl ScanInput {
|
||||
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec<String>) {
|
||||
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
let c = ImportCandidate {
|
||||
path: path.clone(),
|
||||
format: p.format.clone(),
|
||||
kind: p.kind,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
file_size: p.file_size,
|
||||
captured: p.captured.clone(),
|
||||
duplicate: Some("path".into()),
|
||||
existing: Some(p.id.0),
|
||||
..Default::default()
|
||||
};
|
||||
by_path.insert(path.clone(), (p.id, c));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
|
||||
(input, paths.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
|
||||
/// against the library and earlier candidates). Nothing is added; the probes are kept for the
|
||||
/// import that follows.
|
||||
pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut by_path: HashMap<String, PhotoId> = HashMap::new();
|
||||
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), p.id);
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(f.as_str())).cloned().collect();
|
||||
let (input, paths) = ScanInput::new(s, paths);
|
||||
let out = scan_with(input, &paths, &ScanProgress::default());
|
||||
s.import_probes = out.probes;
|
||||
out.candidates
|
||||
}
|
||||
|
||||
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
|
||||
/// has) when `progress.cancel` is set.
|
||||
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let files = expand(paths, input.skip.as_deref());
|
||||
let todo: Vec<String> = files.iter().filter(|f| !input.by_path.contains_key(f.as_str())).cloned().collect();
|
||||
progress.total.store(todo.len(), Relaxed);
|
||||
// probes from a preceding `scan` are reused when the file is unchanged (same size)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let info = s.import_probes.remove(f)?;
|
||||
let info = input.cache.remove(f)?;
|
||||
let size = std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
(size.is_none() || size == Some(info.file_size)).then_some(info)
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let mut fresh = probe_all(s, &missing).into_iter();
|
||||
progress.done.store(todo.len() - missing.len(), Relaxed);
|
||||
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).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();
|
||||
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
|
||||
let mut seen_hash: HashMap<String, Option<u64>> = by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
let mut out = Vec::with_capacity(files.len());
|
||||
let mut kept = HashMap::new();
|
||||
for f in files {
|
||||
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
|
||||
if let Some(id) = by_path.get(&f) {
|
||||
c.duplicate = Some("path".into());
|
||||
c.existing = Some(id.0);
|
||||
if let Some(p) = s.catalog.photo(*id) {
|
||||
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
|
||||
(p.format.clone(), p.kind, p.width, p.height, p.file_size, p.captured.clone());
|
||||
}
|
||||
if let Some((_, c)) = input.by_path.get(&f) {
|
||||
let mut c = c.clone();
|
||||
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
|
||||
let mut c = ImportCandidate { path: f.clone(), name, ..Default::default() };
|
||||
match probes.remove(&f) {
|
||||
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());
|
||||
c.preview_only = info.preview_only.clone();
|
||||
if let Some(h) = &info.content_hash {
|
||||
match seen_hash.get(h) {
|
||||
Some(existing) => {
|
||||
@@ -335,14 +467,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.import_probes.insert(f, info);
|
||||
kept.insert(f, info);
|
||||
}
|
||||
Some(Err(e)) => c.error = Some(e),
|
||||
None => c.error = Some("not probed".into()),
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
ScanOutput { candidates: out, probes: kept }
|
||||
}
|
||||
|
||||
/// Import files/folders. See the module docs.
|
||||
@@ -357,9 +489,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
// added file in its own storage already, so "copy" there means "add".
|
||||
let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
if mode == ImportMode::Copy && lib_dir.is_none() {
|
||||
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
|
||||
if mode == ImportMode::Copy && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut seq = opts.rename_start.max(1);
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
|
||||
|
||||
@@ -388,7 +528,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
}
|
||||
|
||||
let probed = probe_all(s, &todo);
|
||||
let probed = probe_all(s.media.file_probe.as_ref(), &todo, &ScanProgress::default());
|
||||
crate::memory::release();
|
||||
let now = (s.clock)();
|
||||
let mut ops = promote;
|
||||
@@ -408,9 +548,21 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
let stored = match (mode, &lib_dir) {
|
||||
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
|
||||
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
|
||||
let stored = match (mode, ©_root) {
|
||||
(ImportMode::Copy, Some(root))
|
||||
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
|
||||
{
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
// the template sees the photo as it will be catalogued
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let n = crate::rename::expand(t, &q, seq);
|
||||
seq += 1;
|
||||
n
|
||||
});
|
||||
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
@@ -424,7 +576,21 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
if let Some(h) = &info.content_hash {
|
||||
by_hash.insert(h.clone(), id);
|
||||
}
|
||||
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
let mut info = info;
|
||||
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
|
||||
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
@@ -433,13 +599,14 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
if let Some(x) = packet {
|
||||
match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
|
||||
crate::sidecar::merge_into(&mut p, &sc, &now);
|
||||
report.sidecars += 1;
|
||||
@@ -448,6 +615,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
Err(e) => log::warn!("import {path}: XMP: {e}"),
|
||||
}
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
for k in &opts.keywords {
|
||||
let k = lightcraft_catalog::keywords::clean(k);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
@@ -503,6 +673,7 @@ impl Session {
|
||||
if let Some(info) = self.import_probes.get(&c.path) {
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
}
|
||||
p.develop = std::sync::Arc::new(p.import_defaults());
|
||||
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());
|
||||
|
||||
+139
-1
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
pub mod devices;
|
||||
pub mod export;
|
||||
@@ -21,9 +22,12 @@ pub mod library;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
pub mod preset_import;
|
||||
pub mod preset_luminar;
|
||||
pub mod presets;
|
||||
pub mod rename;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
mod view;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -68,6 +72,9 @@ pub struct Interaction {
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
|
||||
/// that changed, nothing else).
|
||||
pub auto_sync: bool,
|
||||
pub catalog: Catalog,
|
||||
pub source: LibrarySource,
|
||||
pub filter: Filter,
|
||||
@@ -114,11 +121,36 @@ pub struct Session {
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// Metadata presets (`metadata.*`), persisted in prefs.json.
|
||||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||||
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
|
||||
/// persisted in prefs.json.
|
||||
pub curve_presets: Vec<cmd::curves::CurvePreset>,
|
||||
/// Saved colour-label name sets.
|
||||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||||
/// Look-alike signatures by content key (Find Similar).
|
||||
pub signatures: std::collections::HashMap<String, [f32; 64]>,
|
||||
/// Imported `.cube` LUT profiles.
|
||||
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
|
||||
/// The target album B adds to (`None` = the Quick Collection).
|
||||
pub target_album: Option<lightcraft_catalog::AlbumId>,
|
||||
/// Auto import: files seen in the watched folder and their size then (a file is imported once
|
||||
/// its size held between two scans).
|
||||
pub auto_import_seen: std::collections::HashMap<String, u64>,
|
||||
/// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent
|
||||
/// Keywords) and the recently added keywords, newest first.
|
||||
pub keyword_sets: Vec<cmd::keywords::KeywordSet>,
|
||||
pub keyword_set: Option<String>,
|
||||
pub recent_keywords: Vec<String>,
|
||||
/// Before/After: the "before" settings chosen per photo (this session; default: the photo's
|
||||
/// import state). See `cmd/before.rs`.
|
||||
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// The last (or running) Build Previews.
|
||||
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
|
||||
/// Develop defaults applied on import (persisted in prefs.json).
|
||||
pub import_defaults: import::ImportDefaults,
|
||||
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
|
||||
@@ -134,6 +166,7 @@ impl Default for Session {
|
||||
impl Session {
|
||||
pub fn new() -> Session {
|
||||
Session {
|
||||
auto_sync: false,
|
||||
catalog: Catalog::new(),
|
||||
source: LibrarySource::All,
|
||||
filter: Filter::default(),
|
||||
@@ -163,8 +196,20 @@ impl Session {
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
metadata_presets: Vec::new(),
|
||||
filter_presets: Vec::new(),
|
||||
curve_presets: Vec::new(),
|
||||
label_sets: Vec::new(),
|
||||
signatures: Default::default(),
|
||||
lut_profiles: Vec::new(),
|
||||
target_album: None,
|
||||
auto_import_seen: Default::default(),
|
||||
keyword_sets: Vec::new(),
|
||||
keyword_set: None,
|
||||
recent_keywords: Vec::new(),
|
||||
before: Default::default(),
|
||||
import_probes: Default::default(),
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
}
|
||||
@@ -190,6 +235,9 @@ impl Session {
|
||||
self.depth -= 1;
|
||||
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
|
||||
self.previous_active = was_active;
|
||||
if let Some(left) = was_active {
|
||||
self.auto_version(left);
|
||||
}
|
||||
}
|
||||
if r.is_ok() && spec.journal && self.depth == 0 {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
@@ -227,6 +275,30 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
|
||||
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
|
||||
/// Saved with the library but not an undo step.
|
||||
pub fn auto_version(&mut self, id: PhotoId) {
|
||||
let Some(p) = self.catalog.photo(id) else { return };
|
||||
if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut versions = p.versions.clone();
|
||||
let created = (self.clock)();
|
||||
let name = lightcraft_catalog::dates::display_time(&created);
|
||||
versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true });
|
||||
let autos = versions.iter().filter(|v| v.auto).count();
|
||||
if autos > AUTO_VERSIONS
|
||||
&& let Some(i) = versions.iter().position(|v| v.auto)
|
||||
{
|
||||
versions.remove(i);
|
||||
}
|
||||
let op = Op::SetVersions { id, versions };
|
||||
if self.catalog.apply(op.clone()).is_ok() {
|
||||
self.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
||||
/// depends on the state the previous one left).
|
||||
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
||||
@@ -312,10 +384,46 @@ impl Session {
|
||||
{
|
||||
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
|
||||
}
|
||||
let op = self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
|
||||
ops.extend(self.auto_sync_ops(id, &settings, label));
|
||||
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
|
||||
self.commit(label, op)
|
||||
}
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
|
||||
/// other selected photos: only the settings that changed; never spot removal or red eye (they
|
||||
/// belong to one photo's pixels), nor history / snapshot restores.
|
||||
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
|
||||
if !self.auto_sync
|
||||
|| self.active() != Some(id)
|
||||
|| self.selection.ids.len() < 2
|
||||
|| label.starts_with("History:")
|
||||
|| label.starts_with("Restore ")
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(old) = self.develop_of(id) else { return Vec::new() };
|
||||
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
|
||||
if let Some(o) = delta.as_object_mut() {
|
||||
for k in ["spots", "red_eye", "version"] {
|
||||
o.remove(k);
|
||||
}
|
||||
if o.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
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);
|
||||
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
@@ -361,6 +469,8 @@ impl Session {
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
if self.visible_key.as_ref() != Some(&key) {
|
||||
// "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);
|
||||
if self.source == LibrarySource::Folder {
|
||||
// no folder chosen: nothing (an empty path matches nothing)
|
||||
@@ -382,6 +492,18 @@ impl Session {
|
||||
self.visible.reverse();
|
||||
}
|
||||
}
|
||||
if self.source == LibrarySource::RecentlyAdded {
|
||||
// newest import first, whatever the sort (the grid groups by import day)
|
||||
let cat = &self.catalog;
|
||||
self.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))
|
||||
});
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
|
||||
self.visible.retain(|id| missing.contains(id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
self.visible = self.catalog.arrange_stacks(&self.visible);
|
||||
}
|
||||
@@ -406,6 +528,22 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatic versions kept per photo.
|
||||
pub const AUTO_VERSIONS: usize = 20;
|
||||
|
||||
/// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole).
|
||||
pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
|
||||
match (old, new) {
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
let m: serde_json::Map<String, Value> =
|
||||
b.iter().filter_map(|(k, nv)| json_delta(a.get(k).unwrap_or(&Value::Null), nv).map(|d| (k.clone(), d))).collect();
|
||||
(!m.is_empty()).then_some(Value::Object(m))
|
||||
}
|
||||
(a, b) if a == b => None,
|
||||
(_, b) => Some(b.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -108,6 +108,20 @@ struct PrefsFile {
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Metadata presets.
|
||||
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
|
||||
/// Filter presets.
|
||||
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
|
||||
/// Point-curve presets.
|
||||
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
|
||||
/// Colour-label name sets.
|
||||
label_sets: Vec<crate::cmd::manage::LabelSet>,
|
||||
/// Imported LUT profiles.
|
||||
lut_profiles: Vec<crate::cmd::lut_profiles::LutProfile>,
|
||||
/// Keyword sets, the one in use, recent keywords.
|
||||
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
|
||||
keyword_set: Option<String>,
|
||||
recent_keywords: Vec<String>,
|
||||
/// Develop defaults for imported photos.
|
||||
import: crate::import::ImportDefaults,
|
||||
/// Thumbnail disk cache budget (MB, 0 = default).
|
||||
@@ -143,6 +157,7 @@ impl Session {
|
||||
/// this session's catalog.
|
||||
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
|
||||
let LibraryStores { dir, catalog, mut files, on_disk } = stores;
|
||||
self.media.smart_dir = on_disk.then(|| crate::smart::dir(&dir));
|
||||
let (mut journal, catalog, report) = Journal::open(catalog)?;
|
||||
self.catalog = catalog;
|
||||
self.undo.clear();
|
||||
@@ -174,6 +189,15 @@ impl Session {
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
self.label_sets = prefs.label_sets;
|
||||
self.lut_profiles = prefs.lut_profiles;
|
||||
crate::cmd::lut_profiles::register_all(self);
|
||||
self.keyword_sets = prefs.keyword_sets;
|
||||
self.keyword_set = prefs.keyword_set;
|
||||
self.recent_keywords = prefs.recent_keywords;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
// view state
|
||||
@@ -286,6 +310,14 @@ impl Session {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
label_sets: self.label_sets.clone(),
|
||||
lut_profiles: self.lut_profiles.clone(),
|
||||
keyword_sets: self.keyword_sets.clone(),
|
||||
keyword_set: self.keyword_set.clone(),
|
||||
recent_keywords: self.recent_keywords.clone(),
|
||||
import: self.import_defaults.clone(),
|
||||
cache_mb: self.cache_mb,
|
||||
})
|
||||
|
||||
+62
-10
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 3;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 4;
|
||||
|
||||
/// 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];
|
||||
@@ -74,8 +74,19 @@ pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Arc<Rgb32f>),
|
||||
Demo { scene: Box<lightcraft_scenes::Scene>, max_edge: usize },
|
||||
File { path: String, max_edge: usize, loader: Option<FileLoader> },
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
},
|
||||
File {
|
||||
path: String,
|
||||
max_edge: usize,
|
||||
loader: Option<FileLoader>,
|
||||
},
|
||||
/// A smart preview standing in for a missing original.
|
||||
Smart {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl SourceRef {
|
||||
@@ -87,6 +98,10 @@ impl SourceRef {
|
||||
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
},
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SourceRef::Smart { path } => crate::smart::load(path),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +123,8 @@ pub struct MediaCache {
|
||||
pub preview_loader: Option<PreviewLoader>,
|
||||
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
|
||||
pub file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// The library's smart previews folder (originals offline: render from the proxy).
|
||||
pub smart_dir: Option<std::path::PathBuf>,
|
||||
scenes: Vec<lightcraft_scenes::Scene>,
|
||||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||||
pub rendered: Arc<PreviewCache>,
|
||||
@@ -126,6 +143,7 @@ impl Default for MediaCache {
|
||||
file_probe: None,
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
smart_dir: None,
|
||||
scenes: Vec::new(),
|
||||
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
|
||||
}
|
||||
@@ -266,6 +284,16 @@ impl MediaCache {
|
||||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
_ => usize::MAX,
|
||||
});
|
||||
// the original is offline: its smart preview, when there is one
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
|
||||
&& !std::path::Path::new(path).exists()
|
||||
{
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
if sp.exists() {
|
||||
return SourceRef::Smart { path: sp };
|
||||
}
|
||||
}
|
||||
self.origin_ref(&p.source, max_edge)
|
||||
}
|
||||
|
||||
@@ -346,6 +374,20 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is
|
||||
/// never kept as the photo's cached preview.
|
||||
pub fn with_proof(mut self, proof: Option<lightcraft_pipeline::Proof>) -> Self {
|
||||
if self.request.proof != proof {
|
||||
let k = |p: Option<lightcraft_pipeline::Proof>| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f);
|
||||
self.key ^= k(self.request.proof) ^ k(proof);
|
||||
self.request.proof = proof;
|
||||
}
|
||||
if proof.is_some() {
|
||||
self.view_cache = None;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Reuse `stages` across this view's renders.
|
||||
pub fn with_stages(mut self, stages: Arc<StageCache>) -> Self {
|
||||
self.stages = Some(stages);
|
||||
@@ -400,7 +442,13 @@ impl RenderJob {
|
||||
/// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see
|
||||
/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU.
|
||||
pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered {
|
||||
if gpu && let Some(r) = lightcraft_gpu::render(src, info, s, req, stages) {
|
||||
// LUT profiles have no GPU stage: they render on the CPU
|
||||
if gpu
|
||||
&& !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id)
|
||||
&& !s.masks.iter().any(|m| m.visible && m.refine > 0.0)
|
||||
&& req.proof.is_none()
|
||||
&& let Some(r) = lightcraft_gpu::render(src, info, s, req, stages)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
match stages {
|
||||
@@ -475,11 +523,9 @@ pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||||
}
|
||||
if p.kind == MediaKind::Raw {
|
||||
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens }
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
|
||||
// balance and the display tone curve, like any other rendered file.
|
||||
if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
@@ -511,12 +557,15 @@ impl crate::Session {
|
||||
let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) };
|
||||
// the photo id is part of the key: two photos with the same settings and size must not
|
||||
// share a result (a view slot showing photo A would otherwise look current for photo B)
|
||||
// …and so is the file's content: a file changed on disk (Reload) renders afresh
|
||||
let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||||
let key = settings.hash64()
|
||||
^ ((max_w as u64) << 40)
|
||||
^ ((max_h as u64) << 20)
|
||||
^ (apply_crop as u64)
|
||||
^ (level as u64) << 60
|
||||
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15)
|
||||
^ content.rotate_left(17);
|
||||
let cache = thumb_bucket.map(|b| {
|
||||
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
|
||||
(self.media.rendered.clone(), k)
|
||||
@@ -700,6 +749,9 @@ pub struct ProbeInfo {
|
||||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
|
||||
pub xmp: Option<String>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Importing other editors' presets: XMP preset files, classic `.lrtemplate` files (a Lua table
|
||||
//! literal), photos that carry their edits as an XMP packet ("DNG presets" from mobile apps,
|
||||
//! edited JPEG/TIFF), and zip bundles of any of these. Everything ends up as `crs:` properties
|
||||
//! mapped by [`crate::crs`]. Luminar looks (`.lmp`, `.mplumpack` collections) are read by
|
||||
//! [`crate::preset_luminar`].
|
||||
//!
|
||||
//! Written from the formats' public structure (Lua's table syntax, the ZIP application note,
|
||||
//! ISO 16684-1 XMP) and black-box observation; no third-party code or preset content was used.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_develop::Preset;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::crs::Props;
|
||||
|
||||
/// One preset read from a file, with the settings we could not carry over.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub preset: Preset,
|
||||
/// Names of the other editor's settings that have no counterpart here (local masks, its own
|
||||
/// profiles…).
|
||||
pub unmapped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Extensions of files read as presets (beyond our own `.lcpreset`).
|
||||
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
|
||||
|
||||
// ------------------------------------------------------------------------------------- zip
|
||||
|
||||
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
|
||||
/// directories, macOS resource forks and hidden files are skipped.
|
||||
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
|
||||
let tail = bytes.len().saturating_sub(65_557);
|
||||
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
|
||||
let count = u16_at(eocd + 10).ok_or("bad zip")?;
|
||||
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..count {
|
||||
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
|
||||
return Err("bad zip central directory".into());
|
||||
}
|
||||
let method = u16_at(at + 10).ok_or("bad zip")?;
|
||||
let csize = u32_at(at + 20).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
|
||||
let local = u32_at(at + 42).ok_or("bad zip")?;
|
||||
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
|
||||
at += 46 + nlen + xlen + clen;
|
||||
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
|
||||
return Err(format!("{name}: bad local header"));
|
||||
}
|
||||
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
|
||||
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let content = match method {
|
||||
0 => data.to_vec(),
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
|
||||
m => return Err(format!("{name}: unsupported compression method {m}")),
|
||||
};
|
||||
out.push((name, content));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- Lua tables
|
||||
|
||||
/// A value of a Lua table literal (what `.lrtemplate` files contain).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Lua {
|
||||
Nil,
|
||||
Bool(bool),
|
||||
Num(f64),
|
||||
Str(String),
|
||||
/// Positional items and named fields, in file order.
|
||||
Table(Vec<Lua>, Vec<(String, Lua)>),
|
||||
}
|
||||
|
||||
impl Lua {
|
||||
pub fn get(&self, key: &str) -> Option<&Lua> {
|
||||
match self {
|
||||
Lua::Table(_, m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Lua::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LuaParser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl LuaParser<'_> {
|
||||
fn err<T>(&self, what: &str) -> Result<T, String> {
|
||||
Err(format!("{what} at byte {}", self.i))
|
||||
}
|
||||
fn skip_ws(&mut self) {
|
||||
loop {
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.s[self.i..].starts_with(b"--") {
|
||||
self.i += 2;
|
||||
if let Some(level) = self.long_bracket_level() {
|
||||
let _ = self.long_string(level);
|
||||
} else {
|
||||
while self.i < self.s.len() && self.s[self.i] != b'\n' {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// `[[` / `[==[` at the cursor: its level (number of `=`).
|
||||
fn long_bracket_level(&self) -> Option<usize> {
|
||||
let r = &self.s[self.i..];
|
||||
if r.first() != Some(&b'[') {
|
||||
return None;
|
||||
}
|
||||
let eq = r[1..].iter().take_while(|c| **c == b'=').count();
|
||||
(r.get(1 + eq) == Some(&b'[')).then_some(eq)
|
||||
}
|
||||
fn long_string(&mut self, level: usize) -> Result<String, String> {
|
||||
self.i += level + 2;
|
||||
let close = format!("]{}]", "=".repeat(level));
|
||||
let rest = &self.s[self.i..];
|
||||
let end = rest.windows(close.len()).position(|w| w == close.as_bytes()).ok_or("unterminated long string")?;
|
||||
let mut text = String::from_utf8_lossy(&rest[..end]).to_string();
|
||||
if text.starts_with('\n') {
|
||||
text.remove(0);
|
||||
}
|
||||
self.i += end + close.len();
|
||||
Ok(text)
|
||||
}
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
let q = self.s[self.i];
|
||||
self.i += 1;
|
||||
let mut out = Vec::new();
|
||||
while self.i < self.s.len() {
|
||||
let c = self.s[self.i];
|
||||
self.i += 1;
|
||||
match c {
|
||||
c if c == q => return Ok(String::from_utf8_lossy(&out).to_string()),
|
||||
b'\\' => {
|
||||
let e = *self.s.get(self.i).ok_or("bad escape")?;
|
||||
self.i += 1;
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b'0'..=b'9' => {
|
||||
let mut v = (e - b'0') as u32;
|
||||
for _ in 0..2 {
|
||||
match self.s.get(self.i) {
|
||||
Some(d @ b'0'..=b'9') => {
|
||||
v = v * 10 + (d - b'0') as u32;
|
||||
self.i += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out.push(v.min(255) as u8);
|
||||
}
|
||||
b'\n' => out.push(b'\n'),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
self.err("unterminated string")
|
||||
}
|
||||
fn ident(&mut self) -> Option<String> {
|
||||
let start = self.i;
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') {
|
||||
self.i += 1;
|
||||
}
|
||||
(self.i > start && !self.s[start].is_ascii_digit()).then(|| String::from_utf8_lossy(&self.s[start..self.i]).to_string())
|
||||
}
|
||||
fn number(&mut self) -> Result<f64, String> {
|
||||
let start = self.i;
|
||||
if self.s[self.i..].starts_with(b"0x") || self.s[self.i..].starts_with(b"0X") {
|
||||
self.i += 2;
|
||||
let h = self.i;
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_hexdigit() {
|
||||
self.i += 1;
|
||||
}
|
||||
return i64::from_str_radix(std::str::from_utf8(&self.s[h..self.i]).unwrap_or(""), 16).map(|v| v as f64).map_err(|e| e.to_string());
|
||||
}
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'e' | b'E' | b'+' | b'-')) {
|
||||
if matches!(self.s[self.i], b'+' | b'-') && self.i > start && !matches!(self.s[self.i - 1], b'e' | b'E') {
|
||||
break;
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
||||
}
|
||||
fn value(&mut self) -> Result<Lua, String> {
|
||||
self.skip_ws();
|
||||
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
||||
match c {
|
||||
b'{' => self.table(),
|
||||
b'"' | b'\'' => self.string().map(Lua::Str),
|
||||
b'[' if self.long_bracket_level().is_some() => {
|
||||
let level = self.long_bracket_level().unwrap_or(0);
|
||||
self.long_string(level).map(Lua::Str)
|
||||
}
|
||||
b'-' => {
|
||||
self.i += 1;
|
||||
self.skip_ws();
|
||||
match self.value()? {
|
||||
Lua::Num(n) => Ok(Lua::Num(-n)),
|
||||
_ => self.err("bad negation"),
|
||||
}
|
||||
}
|
||||
b'0'..=b'9' | b'.' => self.number().map(Lua::Num),
|
||||
_ => match self.ident().as_deref() {
|
||||
Some("true") => Ok(Lua::Bool(true)),
|
||||
Some("false") => Ok(Lua::Bool(false)),
|
||||
Some("nil") => Ok(Lua::Nil),
|
||||
// a call with one string / table argument, e.g. ZSTR "…" (localisation helpers)
|
||||
Some(_) => {
|
||||
self.skip_ws();
|
||||
match self.s.get(self.i) {
|
||||
Some(b'"' | b'\'' | b'{') => self.value(),
|
||||
Some(b'(') => {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b')') {
|
||||
self.i += 1;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
_ => Ok(Lua::Nil),
|
||||
}
|
||||
}
|
||||
None => self.err("unexpected character"),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn table(&mut self) -> Result<Lua, String> {
|
||||
self.i += 1; // {
|
||||
let (mut arr, mut map) = (Vec::new(), Vec::new());
|
||||
loop {
|
||||
self.skip_ws();
|
||||
match self.s.get(self.i) {
|
||||
None => return self.err("unterminated table"),
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Lua::Table(arr, map));
|
||||
}
|
||||
Some(b',' | b';') => {
|
||||
self.i += 1;
|
||||
continue;
|
||||
}
|
||||
Some(b'[') if self.long_bracket_level().is_none() => {
|
||||
self.i += 1;
|
||||
let k = self.value()?;
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) != Some(&b']') {
|
||||
return self.err("expected ]");
|
||||
}
|
||||
self.i += 1;
|
||||
self.expect_eq()?;
|
||||
let v = self.value()?;
|
||||
match k {
|
||||
Lua::Str(s) => map.push((s, v)),
|
||||
Lua::Num(n) => map.push((format!("{n}"), v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// `name = value`, or a positional value
|
||||
let save = self.i;
|
||||
if let Some(name) = self.ident() {
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b'=') && self.s.get(self.i + 1) != Some(&b'=') {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
map.push((name, v));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.i = save;
|
||||
arr.push(self.value()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn expect_eq(&mut self) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b'=') {
|
||||
self.i += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
self.err("expected =")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
||||
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
|
||||
p.skip_ws();
|
||||
let save = p.i;
|
||||
match p.ident().as_deref() {
|
||||
Some("return") => {}
|
||||
Some(_) => {
|
||||
p.skip_ws();
|
||||
if p.s.get(p.i) == Some(&b'=') {
|
||||
p.i += 1;
|
||||
} else {
|
||||
p.i = save;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
p.value()
|
||||
}
|
||||
|
||||
/// A localisable string `"$$$/Key/Path=Default text"` → its default text.
|
||||
fn delocalize(s: &str) -> String {
|
||||
match s.strip_prefix("$$$/") {
|
||||
Some(rest) => rest.split_once('=').map_or(rest, |(_, t)| t).to_string(),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The develop settings of an `.lrtemplate` as `crs:` properties (flat, and structured for local
|
||||
/// corrections), plus its title.
|
||||
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs_masks::Values), String> {
|
||||
let root = parse_lua(text)?;
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
||||
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
||||
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
||||
let mut props = Props::new();
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
for (k, v) in fields {
|
||||
let key = format!("crs:{k}");
|
||||
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
|
||||
values.insert(key.clone(), crate::crs_masks::from_lua(v));
|
||||
}
|
||||
let vals: Vec<String> = match v {
|
||||
Lua::Num(n) => vec![format!("{n}")],
|
||||
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
|
||||
Lua::Str(s) => vec![delocalize(s)],
|
||||
// point curves are flat lists x1, y1, x2, y2, …
|
||||
Lua::Table(arr, map) if map.is_empty() && !arr.is_empty() && arr.iter().all(|a| matches!(a, Lua::Num(_))) => arr
|
||||
.chunks(2)
|
||||
.filter_map(|c| match c {
|
||||
[Lua::Num(x), Lua::Num(y)] => Some(format!("{x}, {y}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
Lua::Table(a, m) if a.is_empty() && m.is_empty() => continue,
|
||||
// anything else (local corrections, retouch…) is only reported
|
||||
Lua::Table(..) => vec!["(table)".into()],
|
||||
Lua::Nil => continue,
|
||||
};
|
||||
props.insert(key, vals);
|
||||
}
|
||||
Ok((title, props, values))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- XMP in files
|
||||
|
||||
/// The XMP packet embedded in a file of any container (DNG, JPEG, TIFF…): the text from
|
||||
/// `<x:xmpmeta` to `</x:xmpmeta>`.
|
||||
pub fn embedded_xmp(bytes: &[u8]) -> Option<String> {
|
||||
let start = bytes.windows(10).position(|w| w == b"<x:xmpmeta")?;
|
||||
let end = bytes[start..].windows(12).position(|w| w == b"</x:xmpmeta>")? + start + 12;
|
||||
Some(String::from_utf8_lossy(&bytes[start..end]).to_string())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------- files
|
||||
|
||||
/// A preset group named after the innermost folder of `dir` (a path inside the imported folder or
|
||||
/// bundle) that isn't a container named for an app or a format (`Lightroom Classic`, `XMP`,
|
||||
/// `Mobile (DNG)`, `Develop Presets`…).
|
||||
pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
const FILLER: &[&str] = &[
|
||||
"lightroom",
|
||||
"lr",
|
||||
"lrc",
|
||||
"classic",
|
||||
"cc",
|
||||
"camera",
|
||||
"raw",
|
||||
"acr",
|
||||
"photoshop",
|
||||
"desktop",
|
||||
"mobile",
|
||||
"xmp",
|
||||
"lrtemplate",
|
||||
"lrtemplates",
|
||||
"dng",
|
||||
"dngs",
|
||||
"preset",
|
||||
"presets",
|
||||
"settings",
|
||||
"lcpreset",
|
||||
"lightcraft",
|
||||
"user",
|
||||
"develop",
|
||||
"files",
|
||||
"for",
|
||||
"and",
|
||||
"version",
|
||||
"versions",
|
||||
"format",
|
||||
"formats",
|
||||
"new",
|
||||
"old",
|
||||
"adobe",
|
||||
"luminar",
|
||||
"skylum",
|
||||
"looks",
|
||||
"lmp",
|
||||
"mplumpack",
|
||||
"contents",
|
||||
"resources",
|
||||
];
|
||||
let generic = |name: &str| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).all(|w| FILLER.contains(&w) || w.chars().all(|c| c.is_ascii_digit()))
|
||||
};
|
||||
// a macOS bundle (`Look.lmp/Contents/…`) is a file, not a group
|
||||
let bundle = |p: &str| p.to_ascii_lowercase().ends_with(".lmp");
|
||||
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p) && !bundle(p)).map(str::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
|
||||
if from_photo && let Some(o) = settings.as_object_mut() {
|
||||
// a photo's own framing and absolute white balance don't belong in a look
|
||||
o.remove("crop");
|
||||
o.remove("geometry");
|
||||
if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") {
|
||||
o.remove("wb");
|
||||
}
|
||||
}
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let group = props
|
||||
.get("crs:Group")
|
||||
.and_then(|v| v.first())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or(group)
|
||||
.unwrap_or_else(|| "Imported Presets".into());
|
||||
let uuid = props.get("crs:UUID").and_then(|v| v.first()).map(|u| u.to_ascii_lowercase());
|
||||
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
|
||||
Some(Imported { preset: Preset { id, name, group, settings, favorite: false, builtin: false }, unmapped })
|
||||
}
|
||||
|
||||
/// Read the presets in file `name` (`bytes`); `group` names the group when the file doesn't
|
||||
/// (its folder). Zip bundles are read recursively.
|
||||
pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<Vec<Imported>, String> {
|
||||
let path = Path::new(name);
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"zip" => {
|
||||
let mut out = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (inner, data) in read_zip(bytes)? {
|
||||
let iext = Path::new(&inner).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if !(PRESET_EXTS.contains(&iext.as_str()) || iext == crate::presets::LCPRESET_EXT) || iext == "zip" {
|
||||
continue;
|
||||
}
|
||||
let dir_group =
|
||||
inner.rsplit_once('/').and_then(|(d, _)| group_from_dir(d)).or_else(|| group_from_dir(&stem)).or_else(|| group.clone());
|
||||
match read_presets(&inner, &data, dir_group) {
|
||||
Ok(v) => out.extend(v),
|
||||
Err(e) => errors.push(format!("{inner}: {e}")),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(if errors.is_empty() { "no presets in this archive".into() } else { errors.join("; ") });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"lmp" => crate::preset_luminar::read_lmp(name, bytes, group).map(|i| vec![i]),
|
||||
"mplumpack" => crate::preset_luminar::read_mplumpack(name, bytes),
|
||||
"lrtemplate" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
|
||||
build(&props, &values, title.unwrap_or(stem), group, false)
|
||||
.map(|i| vec![i])
|
||||
.ok_or_else(|| "no develop settings we can use in this template".into())
|
||||
}
|
||||
"xmp" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
|
||||
let props = &d.properties;
|
||||
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
|
||||
build(props, &d.values, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
|
||||
}
|
||||
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
|
||||
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
|
||||
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
|
||||
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
|
||||
build(&d.properties, &d.values, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
|
||||
}
|
||||
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
|
||||
v.into_iter()
|
||||
.map(|mut p| {
|
||||
if p.group == "Imported Presets"
|
||||
&& let Some(g) = &group
|
||||
{
|
||||
p.group = g.clone();
|
||||
}
|
||||
Imported { preset: p, unmapped: Vec::new() }
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A zip archive (`deflate`: compress entries) — enough of a writer for the tests.
|
||||
pub(crate) fn zip(entries: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, data) in entries {
|
||||
let (method, body) = if deflate { (8u16, miniz_oxide::deflate::compress_to_vec(data, 6)) } else { (0, data.to_vec()) };
|
||||
let off = out.len() as u32;
|
||||
let head = |sig: &[u8], out: &mut Vec<u8>, central: bool| {
|
||||
out.extend_from_slice(sig);
|
||||
if central {
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 8]); // time, date, crc (not checked)
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
};
|
||||
head(b"PK\x03\x04", &mut out, false);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
head(b"PK\x01\x02", &mut central, true);
|
||||
central.extend_from_slice(&[0; 10]); // comment length, disk, internal + external attributes
|
||||
central.extend_from_slice(&off.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd = out.len() as u32;
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(b"PK\x05\x06\0\0\0\0");
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A template in the classic Lua-table form (written for this test).
|
||||
pub(crate) const TEMPLATE: &str = r#"s = {
|
||||
id = "0D2F9C1E-TEST",
|
||||
internalName = "Warm Fade",
|
||||
title = "$$$/Test/Warm=Warm Fade",
|
||||
type = "Develop",
|
||||
value = {
|
||||
settings = {
|
||||
Exposure2012 = 0.35,
|
||||
Contrast2012 = -20,
|
||||
ConvertToGrayscale = false,
|
||||
ToneCurvePV2012 = { 0, 20, 128, 128, 255, 240, },
|
||||
SplitToningShadowHue = 210,
|
||||
SplitToningShadowSaturation = 15,
|
||||
HueAdjustmentOrange = -8,
|
||||
CameraProfile = "Some Profile",
|
||||
RetouchInfo = {},
|
||||
ProcessVersion = "11.0",
|
||||
-- a comment
|
||||
EnableColorAdjustments = true,
|
||||
},
|
||||
uuid = "0D2F9C1E-TEST",
|
||||
},
|
||||
version = 0,
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn lua_tables() {
|
||||
let v = parse_lua(
|
||||
r#"return { a = 1, ["b c"] = 'x\'y', -2.5e1, 0x10, t = { true, nil }, [[long
|
||||
text]], z = ZSTR "loc" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v.get("a"), Some(&Lua::Num(1.0)));
|
||||
assert_eq!(v.get("b c"), Some(&Lua::Str("x'y".into())));
|
||||
assert_eq!(v.get("z"), Some(&Lua::Str("loc".into())));
|
||||
let Lua::Table(arr, _) = &v else { panic!() };
|
||||
assert_eq!(arr[..2], [Lua::Num(-25.0), Lua::Num(16.0)]);
|
||||
assert_eq!(arr[2], Lua::Str("long\ntext".into()));
|
||||
assert!(parse_lua("s = { a = ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_maps_settings_and_reports_the_rest() {
|
||||
let v = read_presets("Warm Fade.lrtemplate", TEMPLATE.as_bytes(), None).unwrap();
|
||||
assert_eq!(v.len(), 1);
|
||||
let p = &v[0].preset;
|
||||
assert_eq!((p.name.as_str(), p.group.as_str()), ("Warm Fade", "Imported Presets"));
|
||||
assert_eq!(p.settings["light"]["exposure"], json!(0.35));
|
||||
assert_eq!(p.settings["light"]["contrast"], json!(-20.0));
|
||||
assert_eq!(p.settings["treatment"], json!("color"));
|
||||
assert_eq!(p.settings["grading"]["shadows"]["hue"], json!(210.0));
|
||||
assert_eq!(p.settings["mixer"]["orange"]["hue"], json!(-8.0));
|
||||
let c = p.settings["curve"]["master"].as_array().unwrap();
|
||||
assert_eq!(c.len(), 3);
|
||||
assert!((c[0]["y"].as_f64().unwrap() - 20.0 / 255.0).abs() < 1e-9);
|
||||
assert_eq!(v[0].unmapped, vec!["CameraProfile".to_string()], "profiles are ours; the rest is mapped or bookkeeping");
|
||||
// it parses into real develop settings
|
||||
let d = lightcraft_develop::DevelopSettings::default().merged(&p.settings).expect("valid develop settings");
|
||||
assert_eq!(d.light.exposure, 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_process_version_fields_are_approximated() {
|
||||
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
|
||||
let p = &read_presets("Old.lrtemplate", t.as_bytes(), None).unwrap()[0].preset;
|
||||
assert_eq!(p.settings["light"]["exposure"], json!(0.5));
|
||||
assert_eq!(p.settings["light"]["contrast"], json!(25.0), "relative to the old default of 25");
|
||||
assert_eq!(p.settings["light"]["shadows"], json!(20.0));
|
||||
assert_eq!(p.settings["light"]["highlights"], json!(-30.0));
|
||||
assert_eq!(p.settings["effects"]["clarity"], json!(10.0));
|
||||
assert_eq!(p.settings["curve"]["master"].as_array().unwrap().len(), 3);
|
||||
// newer presets carry the old fields at their defaults: those must not shift anything
|
||||
let t2 = "s = { value = { settings = { Contrast2012 = 10, Contrast = 25, Shadows = 5, Brightness = 50 } } }";
|
||||
let p2 = &read_presets("New.lrtemplate", t2.as_bytes(), None).unwrap()[0];
|
||||
assert_eq!(p2.preset.settings, json!({"light": {"contrast": 10.0}}));
|
||||
assert!(p2.unmapped.is_empty(), "{:?}", p2.unmapped);
|
||||
}
|
||||
|
||||
const XMP: &str = 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.50" crs:Vibrance="20" crs:WhiteBalance="As Shot" crs:Temperature="5600" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropRight="0.9" crs:CropTop="0" crs:CropBottom="1"/>
|
||||
</rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn photos_carrying_edits_become_looks() {
|
||||
// a "DNG preset": any container with an XMP packet; framing and white balance stay behind
|
||||
let mut file = b"II*\0 binary junk ".to_vec();
|
||||
file.extend_from_slice(XMP.as_bytes());
|
||||
file.extend_from_slice(b" more junk");
|
||||
let v = read_presets("/x/Moody.dng", &file, None).unwrap();
|
||||
let p = &v[0].preset;
|
||||
assert_eq!(p.name, "Moody");
|
||||
assert_eq!(p.settings, json!({"light": {"exposure": 0.5}, "color": {"vibrance": 20.0}}));
|
||||
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundles_with_folders_as_groups() {
|
||||
for deflate in [false, true] {
|
||||
let z = zip(
|
||||
&[
|
||||
("Pack/Film/Warm Fade.lrtemplate", TEMPLATE.as_bytes()),
|
||||
("Pack/Film/Bright.xmp", XMP.as_bytes()),
|
||||
("__MACOSX/Pack/._Bright.xmp", b"junk"),
|
||||
("Pack/readme.txt", b"thanks for downloading"),
|
||||
],
|
||||
deflate,
|
||||
);
|
||||
let v = read_presets("pack.zip", &z, None).unwrap();
|
||||
let names: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(names, [("Warm Fade", "Film"), ("Bright", "Film")], "deflate={deflate}");
|
||||
}
|
||||
assert!(read_presets("empty.zip", &zip(&[("a.txt", b"x")], true), None).is_err());
|
||||
assert!(read_presets("bad.zip", b"not a zip", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_folder_names_are_not_groups() {
|
||||
assert_eq!(group_from_dir("Settings"), None);
|
||||
assert_eq!(group_from_dir("Develop Presets/"), None);
|
||||
assert_eq!(group_from_dir("Looks/My Film Looks"), Some("My Film Looks".into()));
|
||||
// commercial packs nest by format: the pack's name wins
|
||||
assert_eq!(group_from_dir("Film Pack/Lightroom Classic"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Film Pack/Mobile (DNG)/"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Film Pack/XMP Presets for Lightroom CC 7.3+"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Sacred Light"), Some("Sacred Light".into()), "whole words only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_command_reads_folders_bundles_and_applies() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-preset-import-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Looks/Film")).unwrap();
|
||||
std::fs::write(dir.join("Looks/Film/Warm Fade.lrtemplate"), TEMPLATE).unwrap();
|
||||
std::fs::write(dir.join("Looks/bundle.zip"), zip(&[("Street/Bright.xmp", XMP.as_bytes())], true)).unwrap();
|
||||
std::fs::write(dir.join("Looks/notes.txt"), "not a preset").unwrap();
|
||||
let mut s = crate::Session::with_demo();
|
||||
let before = s.presets.len();
|
||||
let paths = json!([dir.join("Looks").to_string_lossy()]);
|
||||
// a dry run reports without adding
|
||||
let r = s.execute("preset.import", &json!({"paths": paths, "dryRun": true})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert_eq!(s.presets.len(), before);
|
||||
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
||||
let got: Vec<(String, String)> =
|
||||
r["imported"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().into(), i["group"].as_str().unwrap().into())).collect();
|
||||
assert_eq!(got, [("Warm Fade".to_string(), "Film".to_string()), ("Bright".into(), "Street".into())]);
|
||||
assert_eq!(r["imported"][0]["unmapped"], json!(["CameraProfile"]));
|
||||
assert_eq!(s.presets.len(), before + 2);
|
||||
// importing again adds nothing
|
||||
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
||||
assert_eq!((r["imported"].as_array().unwrap().len(), r["skipped"].as_u64()), (0, Some(2)));
|
||||
// and the imported look applies
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
let pid = s.presets.iter().find(|p| p.name == "Warm Fade").unwrap().id.clone();
|
||||
s.execute("preset.apply", &json!({"id": pid})).unwrap();
|
||||
let d = s.develop_of(id).unwrap();
|
||||
assert_eq!((d.light.exposure, d.light.contrast), (0.35, -20.0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
//! Luminar looks: `.lmp` files and `.mplumpack` collections.
|
||||
//!
|
||||
//! An `.lmp` look is an Apple XML property list: a dict whose `AdjustmentLayers` array holds layers
|
||||
//! (`Identifier`, `Enabled`, `Amount` = opacity 0..1, `BlendModeIdentifier`, optional nested
|
||||
//! `Sublayers.AdjustmentLayers`), each with `Effects` (`Identifier` such as
|
||||
//! `MIPLDevelopCommonEffectID`, and `Parameters`: name → `{Value: number}`; parameters at their
|
||||
//! default are left out). Newer looks may be a macOS bundle directory `Name.lmp/Contents/preset.lmp`
|
||||
//! (plus `Info.plist` and LUT resources). An `.mplumpack` is a zip of `.lmp` files with a
|
||||
//! `PresetsInfo.plist` (`GroupName`) and icons.
|
||||
//!
|
||||
//! The look's sliders with a clear counterpart become `crs:` fields (mapped by [`crate::crs`]);
|
||||
//! everything else (AI tools, Orton, glow, LUTs…) is reported as unmapped. Sliders are on a
|
||||
//! −100..100 scale; Exposure is read as ±100 ↔ ±4 EV. Layer opacity scales additive sliders.
|
||||
//!
|
||||
//! Written from black-box inspection of freely distributed looks and the public property-list
|
||||
//! format; no Skylum code or look content was used.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::crs::Props;
|
||||
use crate::preset_import::{Imported, build, group_from_dir, read_zip};
|
||||
|
||||
// ----------------------------------------------------------------------------- property lists
|
||||
|
||||
/// A value of an XML property list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Plist {
|
||||
Dict(Vec<(String, Plist)>),
|
||||
Array(Vec<Plist>),
|
||||
Str(String),
|
||||
Num(f64),
|
||||
Bool(bool),
|
||||
/// `<data>` (base64; not decoded)
|
||||
Data,
|
||||
}
|
||||
|
||||
impl Plist {
|
||||
pub fn get(&self, key: &str) -> Option<&Plist> {
|
||||
match self {
|
||||
Plist::Dict(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Plist::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn num(&self) -> Option<f64> {
|
||||
match self {
|
||||
Plist::Num(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn array(&self) -> &[Plist] {
|
||||
match self {
|
||||
Plist::Array(a) => a,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a str,
|
||||
i: usize,
|
||||
/// Nesting of the element being read (bounded: a hostile file must not overflow the stack).
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
/// Deepest `<dict>` / `<array>` nesting read (real looks nest a handful of levels).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
fn unescape(t: &str) -> String {
|
||||
let mut out = String::with_capacity(t.len());
|
||||
let mut rest = t;
|
||||
while let Some(p) = rest.find('&') {
|
||||
out.push_str(&rest[..p]);
|
||||
rest = &rest[p..];
|
||||
let Some(end) = rest.find(';') else { break };
|
||||
let ent = &rest[1..end];
|
||||
let c = match ent {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
_ => ent
|
||||
.strip_prefix("#x")
|
||||
.or_else(|| ent.strip_prefix("#X"))
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
.or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()))
|
||||
.and_then(char::from_u32),
|
||||
};
|
||||
match c {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn err<T>(&self, what: &str) -> Result<T, String> {
|
||||
Err(format!("property list: {what} at byte {}", self.i))
|
||||
}
|
||||
/// Skip whitespace, the XML declaration, the doctype and comments.
|
||||
fn skip(&mut self) {
|
||||
loop {
|
||||
let r = &self.s[self.i..];
|
||||
let t = r.trim_start();
|
||||
self.i += r.len() - t.len();
|
||||
let close = if t.starts_with("<?") {
|
||||
"?>"
|
||||
} else if t.starts_with("<!--") {
|
||||
"-->"
|
||||
} else if t.starts_with("<!") {
|
||||
">"
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
match t.find(close) {
|
||||
Some(e) => self.i += e + close.len(),
|
||||
None => {
|
||||
self.i = self.s.len();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The next tag: (name, closing, self-closing).
|
||||
fn tag(&mut self) -> Result<(String, bool, bool), String> {
|
||||
self.skip();
|
||||
let r = &self.s[self.i..];
|
||||
if !r.starts_with('<') {
|
||||
return self.err("expected a tag");
|
||||
}
|
||||
let Some(end) = r.find('>') else { return self.err("unterminated tag") };
|
||||
let inner = &r[1..end];
|
||||
self.i += end + 1;
|
||||
let closing = inner.starts_with('/');
|
||||
let selfc = inner.ends_with('/');
|
||||
let name = inner.trim_start_matches('/').trim_end_matches('/').split_whitespace().next().unwrap_or("").to_string();
|
||||
Ok((name, closing, selfc))
|
||||
}
|
||||
fn peek_close(&mut self, name: &str) -> bool {
|
||||
self.skip();
|
||||
let r = &self.s[self.i..];
|
||||
let t = format!("</{name}>");
|
||||
if r.starts_with(&t) {
|
||||
self.i += t.len();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn text(&mut self, name: &str) -> Result<String, String> {
|
||||
let r = &self.s[self.i..];
|
||||
let t = format!("</{name}>");
|
||||
let Some(end) = r.find(&t) else { return self.err(&format!("missing {t}")) };
|
||||
self.i += end + t.len();
|
||||
Ok(unescape(&r[..end]))
|
||||
}
|
||||
fn value(&mut self) -> Result<Plist, String> {
|
||||
if self.depth >= MAX_DEPTH {
|
||||
return self.err("nested too deeply");
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.value_inner();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Plist, String> {
|
||||
let (name, closing, selfc) = self.tag()?;
|
||||
if closing {
|
||||
return self.err(&format!("unexpected </{name}>"));
|
||||
}
|
||||
let leaf = |p: &mut Self, f: &dyn Fn(String) -> Result<Plist, String>| if selfc { f(String::new()) } else { p.text(&name).and_then(f) };
|
||||
match name.as_str() {
|
||||
"plist" => {
|
||||
if selfc {
|
||||
return Ok(Plist::Dict(Vec::new()));
|
||||
}
|
||||
let v = self.value()?;
|
||||
self.peek_close("plist");
|
||||
Ok(v)
|
||||
}
|
||||
"dict" => {
|
||||
let mut m = Vec::new();
|
||||
if !selfc {
|
||||
while !self.peek_close("dict") {
|
||||
let (k, c, sc) = self.tag()?;
|
||||
if k != "key" || c {
|
||||
return self.err("expected <key>");
|
||||
}
|
||||
let key = if sc { String::new() } else { self.text("key")? };
|
||||
m.push((key, self.value()?));
|
||||
}
|
||||
}
|
||||
Ok(Plist::Dict(m))
|
||||
}
|
||||
"array" => {
|
||||
let mut a = Vec::new();
|
||||
if !selfc {
|
||||
while !self.peek_close("array") {
|
||||
a.push(self.value()?);
|
||||
}
|
||||
}
|
||||
Ok(Plist::Array(a))
|
||||
}
|
||||
"string" | "date" => leaf(self, &|t| Ok(Plist::Str(t))),
|
||||
"real" | "integer" => {
|
||||
leaf(self, &|t| t.trim().parse::<f64>().map(Plist::Num).map_err(|_| format!("property list: bad number {:?}", t.trim())))
|
||||
}
|
||||
"data" => leaf(self, &|_| Ok(Plist::Data)),
|
||||
"true" | "false" => {
|
||||
if !selfc {
|
||||
self.text(&name)?;
|
||||
}
|
||||
Ok(Plist::Bool(name == "true"))
|
||||
}
|
||||
_ => self.err(&format!("unknown element <{name}>")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an XML property list.
|
||||
pub fn parse_plist(bytes: &[u8]) -> Result<Plist, String> {
|
||||
if bytes.starts_with(b"bplist") {
|
||||
return Err("binary property lists are not supported".into());
|
||||
}
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut p = Parser { s: text.trim_start_matches('\u{feff}'), i: 0, depth: 0 };
|
||||
p.value()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ mapping
|
||||
|
||||
/// Luminar's colour bands (`h`/`s`/`l` + band in the HSL tool) — the same eight as `crs:`.
|
||||
const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
|
||||
|
||||
/// Effects whose parameters carry the basic tone / colour sliders under their own names.
|
||||
const BASIC: &[&str] = &[
|
||||
"MIPLDevelopCommonEffectID",
|
||||
"MIPLExposureEffect",
|
||||
"MIPLContrastEffect",
|
||||
"MIPLHighlightsEffect",
|
||||
"MIPLBlackWhiteEffect",
|
||||
"MIPLWhiteBalanceEffect",
|
||||
"MIPLSaturationEffect",
|
||||
"MIPLVibranceEffect",
|
||||
"MIPLClarityEffect",
|
||||
];
|
||||
|
||||
/// How one Luminar slider maps: the `crs:` field, a scale, whether it adds up across layers
|
||||
/// (and fades with layer opacity), and its clamp range.
|
||||
struct Target {
|
||||
crs: String,
|
||||
scale: f64,
|
||||
additive: bool,
|
||||
range: (f64, f64),
|
||||
}
|
||||
|
||||
fn target(effect: &str, param: &str, value: f64) -> Option<Target> {
|
||||
let t = |crs: &str, scale: f64, additive: bool, range: (f64, f64)| Some(Target { crs: crs.to_string(), scale, additive, range });
|
||||
let pm = (-100.0, 100.0);
|
||||
if BASIC.contains(&effect) {
|
||||
return match param {
|
||||
"Exposure" => t("Exposure2012", 4.0 / 100.0, true, (-5.0, 5.0)),
|
||||
"Contrast" => t("Contrast2012", 1.0, true, pm),
|
||||
"Highlights" => t("Highlights2012", 1.0, true, pm),
|
||||
"Shadows" => t("Shadows2012", 1.0, true, pm),
|
||||
"Whites" => t("Whites2012", 1.0, true, pm),
|
||||
"Blacks" => t("Blacks2012", 1.0, true, pm),
|
||||
// an absolute colour temperature (raw develop) vs. a relative shift
|
||||
"Temperature" if value.abs() > 1000.0 => t("Temperature", 1.0, false, (2000.0, 50000.0)),
|
||||
"Temperature" => t("IncrementalTemperature", 1.0, true, pm),
|
||||
"Tint" => t("IncrementalTint", 1.0, true, pm),
|
||||
"Saturation" => t("Saturation", 1.0, true, pm),
|
||||
"Vibrance" => t("Vibrance", 1.0, true, pm),
|
||||
"Clarity" => t("Clarity2012", 1.0, true, pm),
|
||||
"Dehaze" => t("Dehaze", 1.0, true, pm),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match (effect, param) {
|
||||
("MIPLAIStructureEffect" | "MIPLStructureEffect", "Amount") => t("Clarity2012", 1.0, true, pm),
|
||||
("MIPLDehazeEffect", "Amount") => t("Dehaze", 1.0, true, pm),
|
||||
("MIPLVignetteEffect", "Amount") => t("PostCropVignetteAmount", 1.0, true, pm),
|
||||
("MIPLVignetteEffect", "Vignette Size" | "Size") => t("PostCropVignetteMidpoint", 1.0, false, (0.0, 100.0)),
|
||||
("MIPLGrainNewEffect" | "MIPLGrainEffect", "Amount") => t("GrainAmount", 1.0, true, (0.0, 100.0)),
|
||||
("MIPLChannelsEffect" | "MIPLHSLEffect", _) => {
|
||||
let (kind, band) = param.split_at(param.char_indices().nth(1).map_or(param.len(), |(i, _)| i));
|
||||
let field = match kind {
|
||||
"h" => "HueAdjustment",
|
||||
"s" => "SaturationAdjustment",
|
||||
"l" => "LuminanceAdjustment",
|
||||
_ => return None,
|
||||
};
|
||||
BANDS.contains(&band).then(|| Target { crs: format!("{field}{band}"), scale: 1.0, additive: true, range: pm })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A point curve stored as `OptionalData`: a leading value, then x, y pairs in 0..1 with x
|
||||
/// increasing. Anything else isn't read.
|
||||
fn curve_points(param: &Plist) -> Option<Vec<(f64, f64)>> {
|
||||
let data: Vec<f64> = param.get("OptionalData")?.array().iter().map(Plist::num).collect::<Option<_>>()?;
|
||||
if data.len() < 5 || data.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let pts: Vec<(f64, f64)> = data[1..].chunks(2).map(|c| (c[0], c[1])).collect();
|
||||
let ok = pts.iter().all(|&(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)) && pts.windows(2).all(|w| w[1].0 > w[0].0);
|
||||
ok.then_some(pts)
|
||||
}
|
||||
|
||||
/// `MIPLSmartContrastEffect` → `SmartContrast`.
|
||||
fn short(effect: &str) -> &str {
|
||||
let e = effect.strip_prefix("MIPL").unwrap_or(effect);
|
||||
["_v1_Effect", "EffectID", "Effect"].iter().find_map(|s| e.strip_suffix(s)).unwrap_or(e)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Acc {
|
||||
sums: BTreeMap<String, (f64, (f64, f64))>,
|
||||
curves: BTreeMap<String, Vec<(f64, f64)>>,
|
||||
unmapped: Vec<String>,
|
||||
}
|
||||
|
||||
impl Acc {
|
||||
fn skip(&mut self, what: String) {
|
||||
if !self.unmapped.contains(&what) {
|
||||
self.unmapped.push(what);
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(&mut self, layers: &[Plist], weight: f64) {
|
||||
for layer in layers {
|
||||
if matches!(layer.get("Enabled"), Some(Plist::Bool(false))) {
|
||||
continue;
|
||||
}
|
||||
let opacity = weight * layer.get("Amount").and_then(Plist::num).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let blend = layer.get("BlendModeIdentifier").and_then(Plist::str).unwrap_or("Normal");
|
||||
// a layer with a mask applies locally: not a global look
|
||||
let empty = |v: &Plist| {
|
||||
matches!(v, Plist::Bool(false)) || matches!(v, Plist::Dict(d) if d.is_empty()) || matches!(v, Plist::Array(a) if a.is_empty())
|
||||
};
|
||||
let masked = matches!(layer, Plist::Dict(m) if m.iter().any(|(k, v)| k.to_ascii_lowercase().contains("mask") && !empty(v)));
|
||||
let literal = blend.eq_ignore_ascii_case("normal") && !masked;
|
||||
let mut reported = false;
|
||||
for effect in layer.get("Effects").map(Plist::array).unwrap_or_default() {
|
||||
let id = effect.get("Identifier").and_then(Plist::str).unwrap_or("?");
|
||||
let Some(Plist::Dict(params)) = effect.get("Parameters") else { continue };
|
||||
for (name, pv) in params {
|
||||
let value = pv.get("Value").and_then(Plist::num).or(pv.num());
|
||||
if literal && id == "MIPLCurveEffect" {
|
||||
let crs = match name.as_str() {
|
||||
"RGB" => Some("ToneCurvePV2012"),
|
||||
"Red" => Some("ToneCurvePV2012Red"),
|
||||
"Green" => Some("ToneCurvePV2012Green"),
|
||||
"Blue" => Some("ToneCurvePV2012Blue"),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(crs), Some(pts)) = (crs, curve_points(pv))
|
||||
&& !self.curves.contains_key(crs)
|
||||
{
|
||||
// a curve at partial opacity: the points fade toward the identity
|
||||
self.curves.insert(crs.to_string(), pts.into_iter().map(|(x, y)| (x, x + (y - x) * opacity)).collect());
|
||||
continue;
|
||||
}
|
||||
if pv.get("OptionalData").is_some() {
|
||||
self.skip(format!("{}.{name}", short(id)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(v) = value.filter(|v| *v != 0.0) else { continue };
|
||||
match target(id, name, v).filter(|_| literal) {
|
||||
Some(t) => {
|
||||
let x = v * t.scale * if t.additive { opacity } else { 1.0 };
|
||||
let e = self.sums.entry(t.crs).or_insert((0.0, t.range));
|
||||
e.0 = if t.additive { e.0 + x } else { x };
|
||||
}
|
||||
None => {
|
||||
reported = true;
|
||||
self.skip(format!("{}.{name}", short(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !literal && reported {
|
||||
let lid = layer.get("Identifier").and_then(Plist::str).unwrap_or("layer");
|
||||
self.skip(if masked { format!("{lid}: mask") } else { format!("{lid}: blend mode {blend}") });
|
||||
}
|
||||
if let Some(sub) = layer.get("Sublayers").and_then(|s| s.get("AdjustmentLayers")) {
|
||||
self.layers(sub.array(), opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of a look stored at `path`: its file name, or the bundle's for
|
||||
/// `Name.lmp/Contents/preset.lmp`.
|
||||
fn look_name(path: &str) -> String {
|
||||
let p = Path::new(path);
|
||||
let stem = |p: &Path| p.file_stem().map(|s| s.to_string_lossy().to_string());
|
||||
let bundle = p.parent().filter(|d| d.file_name().is_some_and(|n| n.eq_ignore_ascii_case("Contents"))).and_then(Path::parent);
|
||||
match bundle {
|
||||
Some(b) if b.extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) => stem(b),
|
||||
_ => stem(p),
|
||||
}
|
||||
.unwrap_or_else(|| "Look".into())
|
||||
}
|
||||
|
||||
/// Read one Luminar look (`.lmp`) into a preset in `group` (default "Imported Presets").
|
||||
pub fn read_lmp(path: &str, bytes: &[u8], group: Option<String>) -> Result<Imported, String> {
|
||||
let root = parse_plist(bytes)?;
|
||||
let layers = root.get("AdjustmentLayers").ok_or("not a Luminar look (no adjustment layers)")?;
|
||||
let mut acc = Acc::default();
|
||||
acc.layers(layers.array(), 1.0);
|
||||
let mut props = Props::new();
|
||||
for (k, (v, (lo, hi))) in &acc.sums {
|
||||
props.insert(format!("crs:{k}"), vec![format!("{}", v.clamp(*lo, *hi))]);
|
||||
}
|
||||
for (k, pts) in &acc.curves {
|
||||
props.insert(format!("crs:{k}"), pts.iter().map(|(x, y)| format!("{}, {}", x * 255.0, y * 255.0)).collect());
|
||||
}
|
||||
if let Some(u) = root.get("uuid").and_then(Plist::str).filter(|u| !u.trim().is_empty()) {
|
||||
props.insert("crs:UUID".into(), vec![u.trim().to_string()]);
|
||||
}
|
||||
let name = root.get("Name").and_then(Plist::str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| look_name(path));
|
||||
match build(&props, &Default::default(), name, group, false) {
|
||||
Some(mut i) => {
|
||||
i.preset.id = i.preset.id.replacen("user.xmp.", "user.lmp.", 1);
|
||||
i.unmapped.extend(acc.unmapped);
|
||||
Ok(i)
|
||||
}
|
||||
None if acc.unmapped.is_empty() => Err("this look has no adjustments".into()),
|
||||
None => Err(format!("none of this look's settings have a counterpart here ({})", acc.unmapped.join(", "))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a Luminar looks collection (`.mplumpack`, a zip): every look, grouped by the pack's
|
||||
/// `GroupName` (else the pack's file name).
|
||||
pub fn read_mplumpack(path: &str, bytes: &[u8]) -> Result<Vec<Imported>, String> {
|
||||
let entries = read_zip(bytes)?;
|
||||
let info = entries
|
||||
.iter()
|
||||
.find(|(n, _)| n.rsplit('/').next().is_some_and(|b| b.eq_ignore_ascii_case("PresetsInfo.plist")))
|
||||
.and_then(|(_, b)| parse_plist(b).ok())
|
||||
.and_then(|p| p.get("GroupName").and_then(Plist::str).map(str::trim).filter(|g| !g.is_empty()).map(str::to_string));
|
||||
let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string());
|
||||
let pack = info.or_else(|| stem.as_deref().and_then(group_from_dir)).unwrap_or_else(|| "Luminar Looks".into());
|
||||
let mut out = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (inner, data) in &entries {
|
||||
if !Path::new(inner).extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) {
|
||||
continue;
|
||||
}
|
||||
match read_lmp(inner, data, Some(pack.clone())) {
|
||||
Ok(i) => out.push(i),
|
||||
Err(e) => errors.push(format!("{inner}: {e}")),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(if errors.is_empty() { "no looks in this collection".into() } else { errors.join("; ") });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::preset_import::read_presets;
|
||||
use serde_json::json;
|
||||
|
||||
/// One effect's parameter as Luminar writes it.
|
||||
fn param(name: &str, v: f64) -> String {
|
||||
format!("<key>{name}</key><dict><key>OptionalDataType</key><integer>0</integer><key>Value</key><real>{v}</real></dict>")
|
||||
}
|
||||
|
||||
fn layer(id: &str, amount: f64, blend: &str, effects: &[(&str, String)], sub: &str) -> String {
|
||||
let fx: String = effects
|
||||
.iter()
|
||||
.map(|(e, p)| format!("<dict><key>Identifier</key><string>{e}</string><key>Parameters</key><dict>{p}</dict></dict>"))
|
||||
.collect();
|
||||
let sub =
|
||||
if sub.is_empty() { String::new() } else { format!("<key>Sublayers</key><dict><key>AdjustmentLayers</key><array>{sub}</array></dict>") };
|
||||
format!(
|
||||
"<dict><key>Amount</key><real>{amount}</real><key>BlendModeIdentifier</key><string>{blend}</string><key>Effects</key><array>{fx}</array>\
|
||||
<key>Enabled</key><true/><key>Identifier</key><string>{id}</string>{sub}</dict>"
|
||||
)
|
||||
}
|
||||
|
||||
fn look(layers: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||
<plist version=\"1.0\"><dict><key>AdjustmentLayers</key><array>{layers}</array>{extra}<key>group_identifier</key><string>Custom</string></dict></plist>"
|
||||
)
|
||||
}
|
||||
|
||||
/// A newer-style look (develop sliders nested in sublayers) written for this test.
|
||||
fn modern() -> String {
|
||||
let curve = "<key>RGB</key><dict><key>OptionalData</key><array><real>0.5</real><real>0</real><real>0.1</real><real>0.5</real><real>0.5</real><real>1</real><real>0.9</real></array><key>OptionalDataType</key><integer>1</integer><key>Value</key><real>50</real></dict>";
|
||||
let sub = [
|
||||
layer(
|
||||
"DevelopAdjustmentSubLayer",
|
||||
1.0,
|
||||
"Normal",
|
||||
&[(
|
||||
"MIPLDevelopCommonEffectID",
|
||||
param("Exposure", 25.0) + ¶m("Contrast", 12.0) + ¶m("Highlights", -30.0) + ¶m("Temperature", 15.0),
|
||||
)],
|
||||
&layer("CurveLayer", 1.0, "Normal", &[("MIPLCurveEffect", curve.to_string())], ""),
|
||||
),
|
||||
layer("Disabled", 1.0, "Normal", &[("MIPLDevelopCommonEffectID", param("Shadows", 50.0))], "").replace("<true/>", "<false/>"),
|
||||
]
|
||||
.concat();
|
||||
let layers = [
|
||||
layer("DevelopAdjustmentLayer", 1.0, "Normal", &[], &sub),
|
||||
layer("AIStructureEffect", 1.0, "Normal", &[("MIPLAIStructureEffect", param("Amount", 20.0) + ¶m("Boost", 10.0))], ""),
|
||||
layer(
|
||||
"Half",
|
||||
0.5,
|
||||
"Normal",
|
||||
&[("MIPLVibranceEffect", param("Vibrance", 30.0)), ("MIPLSaturationEffect", param("Saturation", -10.0))],
|
||||
"",
|
||||
),
|
||||
layer("Hsl", 1.0, "Normal", &[("MIPLChannelsEffect", param("hOrange", -8.0) + ¶m("sBlue", 12.0) + ¶m("lGreen", 4.0))], ""),
|
||||
layer("Vignette", 1.0, "Normal", &[("MIPLVignetteEffect", param("Amount", -20.0) + ¶m("Vignette Size", 40.0))], ""),
|
||||
layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""),
|
||||
layer("Screened", 1.0, "Screen", &[("MIPLContrastEffect", param("Contrast", 40.0))], ""),
|
||||
layer("Untouched", 1.0, "Normal", &[("MIPLSkyEnhancerEffect", String::new())], ""),
|
||||
]
|
||||
.concat();
|
||||
look(&layers, "<key>uuid</key><string>AB12-CD34</string><key>kMPPresetIdentifierKey</key><string>x 2.lmp</string>")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plists_parse() {
|
||||
let p = parse_plist(b"<?xml version=\"1.0\"?><!-- c --><plist><dict><key>a&b</key><string>x <yA</string><key>n</key><integer>-3</integer><key>e</key><dict/><key>t</key><true/><key>d</key><data>AAEC</data><key>s</key><string/></dict></plist>").unwrap();
|
||||
assert_eq!(p.get("a&b").and_then(Plist::str), Some("x <yA"));
|
||||
assert_eq!(p.get("n").and_then(Plist::num), Some(-3.0));
|
||||
assert_eq!(p.get("e"), Some(&Plist::Dict(vec![])));
|
||||
assert_eq!(p.get("t"), Some(&Plist::Bool(true)));
|
||||
assert_eq!(p.get("d"), Some(&Plist::Data));
|
||||
assert_eq!(p.get("s").and_then(Plist::str), Some(""));
|
||||
assert!(parse_plist(b"<plist><dict><key>a</key>").is_err());
|
||||
assert!(parse_plist(b"bplist00\x01\x02").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_nesting_is_an_error_not_a_crash() {
|
||||
let deep = format!("<plist>{}{}</plist>", "<array>".repeat(200_000), "</array>".repeat(200_000));
|
||||
assert!(parse_plist(deep.as_bytes()).unwrap_err().contains("nested too deeply"));
|
||||
let ok = format!("<plist>{}<true/>{}</plist>", "<array>".repeat(20), "</array>".repeat(20));
|
||||
assert!(parse_plist(ok.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_sliders_map_and_the_rest_is_reported() {
|
||||
let i = read_presets("/x/Soft Contrast.lmp", modern().as_bytes(), None).unwrap().remove(0);
|
||||
let p = &i.preset;
|
||||
assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Soft Contrast", "Imported Presets", "user.lmp.ab12-cd34"));
|
||||
let s = &p.settings;
|
||||
assert_eq!(s["light"]["exposure"], json!(1.0), "±100 ↔ ±4 EV");
|
||||
assert_eq!(s["light"]["contrast"], json!(12.0));
|
||||
assert_eq!(s["light"]["highlights"], json!(-30.0));
|
||||
assert!(s["light"].get("shadows").is_none(), "disabled layers are ignored");
|
||||
assert_eq!(s["effects"]["clarity"], json!(20.0), "Structure → clarity");
|
||||
assert_eq!(s["color"]["vibrance"], json!(15.0), "layer opacity scales the slider");
|
||||
assert_eq!(s["color"]["saturation"], json!(-5.0));
|
||||
assert_eq!(s["mixer"]["orange"]["hue"], json!(-8.0));
|
||||
assert_eq!(s["mixer"]["blue"]["sat"], json!(12.0));
|
||||
assert_eq!(s["mixer"]["green"]["lum"], json!(4.0));
|
||||
assert_eq!(s["vignette"]["amount"], json!(-20.0));
|
||||
assert_eq!(s["vignette"]["midpoint"], json!(40.0));
|
||||
assert!(s["wb"]["temp"].as_f64().unwrap() > 6500.0, "a warm shift: {}", s["wb"]);
|
||||
let c = s["curve"]["master"].as_array().unwrap();
|
||||
assert_eq!(c.len(), 3);
|
||||
assert!((c[0]["y"].as_f64().unwrap() - 0.1).abs() < 1e-9 && (c[2]["y"].as_f64().unwrap() - 0.9).abs() < 1e-9);
|
||||
assert_eq!(i.unmapped, ["AIStructure.Boost", "OrtonFilter.Amount", "Contrast.Contrast", "Screened: blend mode Screen"]);
|
||||
assert!(s["light"]["contrast"] == json!(12.0), "the screen-blended layer isn't folded in");
|
||||
lightcraft_develop::DevelopSettings::default().merged(s).expect("valid develop settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_looks_and_bundles() {
|
||||
// the older flat layout: one layer per tool, the look's name inside
|
||||
let layers = [
|
||||
layer(
|
||||
"ToneLayer",
|
||||
1.0,
|
||||
"Normal",
|
||||
&[("MIPLExposureEffect", param("Exposure", 0.0)), ("MIPLHighlightsEffect", param("Shadows", 10.0))],
|
||||
"",
|
||||
),
|
||||
layer("ClarityLayer", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""),
|
||||
layer("Wb", 1.0, "Normal", &[("MIPLWhiteBalanceEffect", param("Temperature", -15.0) + ¶m("Tint", 10.0))], ""),
|
||||
]
|
||||
.concat();
|
||||
let lmp = look(&layers, "<key>Name</key><string>Blue Hour</string>");
|
||||
let i = read_presets("whatever.lmp", lmp.as_bytes(), Some("Mine".into())).unwrap().remove(0);
|
||||
assert_eq!((i.preset.name.as_str(), i.preset.group.as_str()), ("Blue Hour", "Mine"));
|
||||
assert_eq!(i.preset.settings["light"], json!({"shadows": 10.0}));
|
||||
assert_eq!(i.preset.settings["effects"]["clarity"], json!(20.0));
|
||||
assert_eq!(i.preset.settings["wb"]["tint"], json!(10.0));
|
||||
assert!(i.preset.settings["wb"]["temp"].as_f64().unwrap() < 6500.0);
|
||||
assert!(i.unmapped.is_empty(), "{:?}", i.unmapped);
|
||||
// a bundle's inner file is named after the bundle
|
||||
let b = read_presets("/p/Vintage.lmp/Contents/preset.lmp", modern().as_bytes(), None).unwrap();
|
||||
assert_eq!(b[0].preset.name, "Vintage");
|
||||
// nothing we can carry over: an error that says what was there
|
||||
let only = look(&layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""), "");
|
||||
let e = read_presets("Glow.lmp", only.as_bytes(), None).unwrap_err();
|
||||
assert!(e.contains("OrtonFilter.Amount"), "{e}");
|
||||
assert!(read_presets("x.lmp", b"<plist><dict/></plist>", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_group_by_their_name() {
|
||||
use crate::preset_import::tests::zip;
|
||||
let a = look(&layer("C", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""), "<key>Name</key><string>Pop 1</string>");
|
||||
let b = look(&layer("S", 1.0, "Normal", &[("MIPLSaturationEffect", param("Saturation", 25.0))], ""), "");
|
||||
let info = b"<?xml version=\"1.0\"?><plist version=\"1.0\"><dict><key>GroupName</key><string>Magic Light</string></dict></plist>";
|
||||
let pack = zip(
|
||||
&[
|
||||
("Pop 1.lmp", a.as_bytes()),
|
||||
("__MACOSX/._Pop 1.lmp", b"junk"),
|
||||
("Pop 2.lmp", b.as_bytes()),
|
||||
("icon@2x.png", b"\x89PNG"),
|
||||
("PresetsInfo.plist", info),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let v = read_presets("/d/Magic Light-2.mplumpack", &pack, None).unwrap();
|
||||
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(got, [("Pop 1", "Magic Light"), ("Pop 2", "Magic Light")]);
|
||||
// without PresetsInfo.plist the pack's file name is the group
|
||||
let v = read_presets("Seaside Looks.mplumpack", &zip(&[("Pop 2.lmp", b.as_bytes())], false), None).unwrap();
|
||||
assert_eq!(v[0].preset.group, "Seaside Looks");
|
||||
// a pack inside a downloaded zip, and bundle-style looks in a zip folder
|
||||
let outer = zip(&[("Archive/Magic Light-2.mplumpack", &pack), ("Archive/Magic Light.plist", b"<plist/>")], false);
|
||||
assert_eq!(read_presets("download.zip", &outer, None).unwrap().len(), 2);
|
||||
let bundles = zip(
|
||||
&[
|
||||
("Wild Pack/Vintage.lmp/Contents/preset.lmp", modern().as_bytes()),
|
||||
("Wild Pack/Vintage.lmp/Contents/Info.plist", b"<plist><dict/></plist>"),
|
||||
("Wild Pack/Flat.lmp", a.as_bytes()),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let v = read_presets("wild.zip", &bundles, None).unwrap();
|
||||
let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(got, [("Vintage", "Wild Pack"), ("Pop 1", "Wild Pack")]);
|
||||
assert!(read_presets("empty.mplumpack", &zip(&[("icon.png", b"x")], false), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_command_reads_looks_and_bundle_folders() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-luminar-import-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Wild/Vintage.lmp/Contents/resources")).unwrap();
|
||||
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/preset.lmp"), modern()).unwrap();
|
||||
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/Info.plist"), "<plist><dict/></plist>").unwrap();
|
||||
std::fs::write(dir.join("Wild/Vintage.lmp/Contents/PkgInfo"), "LMP?????").unwrap();
|
||||
let mut s = crate::Session::with_demo();
|
||||
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild").to_string_lossy()]})).unwrap();
|
||||
let im = &r["imported"][0];
|
||||
assert_eq!((im["name"].as_str(), im["group"].as_str()), (Some("Vintage"), Some("Wild")), "{r}");
|
||||
assert!(im["unmapped"].as_array().unwrap().iter().any(|u| u == "OrtonFilter.Amount"));
|
||||
// a dropped bundle folder on its own
|
||||
let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild/Vintage.lmp").to_string_lossy()], "dryRun": true})).unwrap();
|
||||
assert_eq!(r["imported"][0]["group"], json!("Imported Presets"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
|
||||
}
|
||||
} else {
|
||||
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if top || ext == LCPRESET_EXT || ext == "xmp" {
|
||||
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,151 @@ pub fn builtin() -> Vec<Preset> {
|
||||
"Cinematic",
|
||||
json!({"curve": {"shadows": 20.0, "highlights": -10.0}, "grading": {"shadows": {"hue": 190.0, "sat": 25.0, "lum": 0.0}, "highlights": {"hue": 30.0, "sat": 18.0, "lum": 0.0}}, "light": {"contrast": 15.0}, "vignette": {"amount": -20.0}}),
|
||||
),
|
||||
// ---- Portrait
|
||||
p(
|
||||
"Portrait",
|
||||
"moody-portrait",
|
||||
"Moody Portrait",
|
||||
json!({"light": {"exposure": -0.25, "contrast": 20.0, "highlights": -30.0, "blacks": -12.0}, "color": {"saturation": -15.0}, "vignette": {"amount": -25.0, "midpoint": 40.0}, "mixer": {"orange": {"sat": 6.0}}}),
|
||||
),
|
||||
p(
|
||||
"Portrait",
|
||||
"golden-glow",
|
||||
"Golden Glow",
|
||||
json!({"grading": {"highlights": {"hue": 40.0, "sat": 18.0, "lum": 0.0}, "midtones": {"hue": 35.0, "sat": 8.0, "lum": 0.0}}, "light": {"shadows": 15.0}, "effects": {"clarity": -8.0}}),
|
||||
),
|
||||
p(
|
||||
"Portrait",
|
||||
"clean-studio",
|
||||
"Clean Studio",
|
||||
json!({"light": {"contrast": 10.0, "whites": 15.0, "blacks": -8.0}, "effects": {"texture": -10.0}, "color": {"vibrance": 5.0}}),
|
||||
),
|
||||
// ---- Landscape
|
||||
p(
|
||||
"Landscape",
|
||||
"crisp-vista",
|
||||
"Crisp Vista",
|
||||
json!({"effects": {"dehaze": 15.0, "clarity": 18.0, "texture": 12.0}, "light": {"contrast": 12.0, "highlights": -25.0, "shadows": 18.0}, "color": {"vibrance": 20.0}}),
|
||||
),
|
||||
p(
|
||||
"Landscape",
|
||||
"deep-sky",
|
||||
"Deep Sky",
|
||||
json!({"mixer": {"blue": {"lum": -25.0, "sat": 15.0}, "aqua": {"lum": -10.0}}, "light": {"highlights": -30.0}, "effects": {"dehaze": 10.0}}),
|
||||
),
|
||||
p(
|
||||
"Landscape",
|
||||
"lush-greens",
|
||||
"Lush Greens",
|
||||
json!({"mixer": {"green": {"hue": 12.0, "sat": 18.0, "lum": 8.0}, "yellow": {"hue": 10.0, "sat": 10.0}}, "color": {"vibrance": 12.0}, "effects": {"clarity": 8.0}}),
|
||||
),
|
||||
p(
|
||||
"Landscape",
|
||||
"desert-warmth",
|
||||
"Desert Warmth",
|
||||
json!({"mixer": {"orange": {"sat": 15.0, "lum": 5.0}, "yellow": {"hue": -8.0, "sat": 10.0}, "blue": {"sat": -10.0}}, "grading": {"highlights": {"hue": 38.0, "sat": 12.0, "lum": 0.0}}, "light": {"contrast": 10.0}}),
|
||||
),
|
||||
p(
|
||||
"Landscape",
|
||||
"misty-morning",
|
||||
"Misty Morning",
|
||||
json!({"effects": {"dehaze": -18.0, "clarity": -12.0}, "light": {"contrast": -18.0, "highlights": -10.0}, "grading": {"shadows": {"hue": 210.0, "sat": 10.0, "lum": 0.0}}, "color": {"saturation": -10.0}}),
|
||||
),
|
||||
// ---- Urban
|
||||
p(
|
||||
"Urban",
|
||||
"gritty-street",
|
||||
"Gritty Street",
|
||||
json!({"effects": {"clarity": 35.0, "texture": 20.0}, "light": {"contrast": 25.0, "blacks": -15.0}, "color": {"saturation": -30.0}, "vignette": {"amount": -18.0}}),
|
||||
),
|
||||
p(
|
||||
"Urban",
|
||||
"neon-night",
|
||||
"Neon Night",
|
||||
json!({"mixer": {"magenta": {"sat": 20.0}, "purple": {"sat": 18.0}, "blue": {"sat": 12.0}, "aqua": {"sat": 15.0}}, "light": {"contrast": 18.0, "blacks": -12.0}, "color": {"vibrance": 15.0}, "grading": {"shadows": {"hue": 250.0, "sat": 15.0, "lum": 0.0}}}),
|
||||
),
|
||||
p(
|
||||
"Urban",
|
||||
"concrete-cool",
|
||||
"Concrete Cool",
|
||||
json!({"grading": {"shadows": {"hue": 205.0, "sat": 14.0, "lum": 0.0}, "highlights": {"hue": 200.0, "sat": 6.0, "lum": 0.0}}, "color": {"saturation": -20.0}, "light": {"contrast": 12.0}, "effects": {"clarity": 15.0}}),
|
||||
),
|
||||
p(
|
||||
"Urban",
|
||||
"faded-urban",
|
||||
"Faded Urban",
|
||||
json!({"curve": {"shadows": 30.0}, "color": {"saturation": -22.0}, "light": {"contrast": -8.0}, "grain": {"amount": 12.0, "size": 25.0, "roughness": 45.0}}),
|
||||
),
|
||||
// ---- Food
|
||||
p(
|
||||
"Food",
|
||||
"fresh-bright",
|
||||
"Fresh & Bright",
|
||||
json!({"light": {"exposure": 0.25, "shadows": 20.0, "whites": 10.0}, "color": {"vibrance": 22.0}, "effects": {"texture": 15.0}}),
|
||||
),
|
||||
p(
|
||||
"Food",
|
||||
"warm-table",
|
||||
"Warm Table",
|
||||
json!({"grading": {"midtones": {"hue": 32.0, "sat": 10.0, "lum": 0.0}}, "mixer": {"orange": {"sat": 10.0}, "red": {"sat": 8.0}}, "light": {"contrast": 8.0}}),
|
||||
),
|
||||
p(
|
||||
"Food",
|
||||
"dark-moody-food",
|
||||
"Dark & Moody",
|
||||
json!({"light": {"exposure": -0.3, "contrast": 22.0, "highlights": -20.0, "blacks": -15.0}, "effects": {"texture": 18.0}, "vignette": {"amount": -22.0}}),
|
||||
),
|
||||
// ---- Seasons
|
||||
p(
|
||||
"Seasons",
|
||||
"autumn-gold",
|
||||
"Autumn Gold",
|
||||
json!({"mixer": {"green": {"hue": -30.0, "sat": -10.0}, "yellow": {"hue": -15.0, "sat": 15.0}, "orange": {"sat": 18.0}}, "grading": {"highlights": {"hue": 40.0, "sat": 10.0, "lum": 0.0}}}),
|
||||
),
|
||||
p(
|
||||
"Seasons",
|
||||
"winter-blue",
|
||||
"Winter Blue",
|
||||
json!({"grading": {"shadows": {"hue": 215.0, "sat": 18.0, "lum": 0.0}, "highlights": {"hue": 205.0, "sat": 8.0, "lum": 0.0}}, "color": {"saturation": -12.0}, "light": {"whites": 12.0}}),
|
||||
),
|
||||
p(
|
||||
"Seasons",
|
||||
"spring-fresh",
|
||||
"Spring Fresh",
|
||||
json!({"mixer": {"green": {"hue": 10.0, "sat": 12.0, "lum": 10.0}, "magenta": {"sat": 10.0}}, "light": {"shadows": 15.0}, "color": {"vibrance": 15.0}}),
|
||||
),
|
||||
p(
|
||||
"Seasons",
|
||||
"summer-haze",
|
||||
"Summer Haze",
|
||||
json!({"effects": {"dehaze": -12.0}, "curve": {"shadows": 18.0}, "grading": {"highlights": {"hue": 45.0, "sat": 14.0, "lum": 0.0}}, "light": {"contrast": -10.0}}),
|
||||
),
|
||||
// ---- Vintage
|
||||
p(
|
||||
"Vintage",
|
||||
"instant-70s",
|
||||
"Instant ’70s",
|
||||
json!({"curve": {"shadows": 28.0, "highlights": -18.0}, "grading": {"shadows": {"hue": 170.0, "sat": 14.0, "lum": 0.0}, "highlights": {"hue": 45.0, "sat": 18.0, "lum": 0.0}}, "color": {"saturation": -10.0}, "vignette": {"amount": -15.0}, "grain": {"amount": 20.0, "size": 30.0, "roughness": 55.0}}),
|
||||
),
|
||||
p(
|
||||
"Vintage",
|
||||
"cross-process",
|
||||
"Cross Process",
|
||||
json!({"grading": {"shadows": {"hue": 230.0, "sat": 25.0, "lum": 0.0}, "highlights": {"hue": 60.0, "sat": 25.0, "lum": 0.0}}, "light": {"contrast": 22.0}, "color": {"saturation": 10.0}}),
|
||||
),
|
||||
p(
|
||||
"Vintage",
|
||||
"bleach-bypass",
|
||||
"Bleach Bypass",
|
||||
json!({"color": {"saturation": -40.0}, "light": {"contrast": 35.0, "highlights": -15.0}, "effects": {"clarity": 15.0}}),
|
||||
),
|
||||
// ---- B&W toners
|
||||
p(
|
||||
"B&W",
|
||||
"bw-sepia",
|
||||
"Sepia Tone",
|
||||
json!({"treatment": "bw", "grading": {"shadows": {"hue": 32.0, "sat": 22.0, "lum": 0.0}, "highlights": {"hue": 42.0, "sat": 18.0, "lum": 0.0}}, "curve": {"shadows": 10.0}}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -312,14 +457,34 @@ impl Session {
|
||||
pub fn profile_menu(&self) -> Value {
|
||||
let item =
|
||||
|p: &ProfileInfo| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.iter().any(|f| f == p.id)});
|
||||
let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id)).map(item).collect::<Vec<_>>();
|
||||
let lut_item = |id: &str| {
|
||||
self.lut_profiles
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true}))
|
||||
};
|
||||
let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id).map(item).or_else(|| lut_item(id))).collect::<Vec<_>>();
|
||||
let mut groups: Vec<Value> = profile_groups()
|
||||
.into_iter()
|
||||
.map(|g| json!({"name": g, "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>()}))
|
||||
.collect();
|
||||
// imported LUT profiles, by their groups
|
||||
let mut lut_groups: Vec<&str> = self.lut_profiles.iter().map(|p| p.group.as_str()).collect();
|
||||
lut_groups.sort_unstable();
|
||||
lut_groups.dedup();
|
||||
for g in lut_groups {
|
||||
let profiles: Vec<Value> = self
|
||||
.lut_profiles
|
||||
.iter()
|
||||
.filter(|p| p.group == g)
|
||||
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true}))
|
||||
.collect();
|
||||
groups.push(json!({"name": g, "profiles": profiles, "imported": true}));
|
||||
}
|
||||
json!({
|
||||
"favorites": list(&self.profile_favorites),
|
||||
"recent": list(&self.profile_recent),
|
||||
"groups": profile_groups().into_iter().map(|g| json!({
|
||||
"name": g,
|
||||
"profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"groups": groups,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct SidecarData {
|
||||
pub rating: Option<u8>,
|
||||
pub flag: Option<Flag>,
|
||||
pub label: Option<Option<ColorLabel>>,
|
||||
/// The label's text as written (`xmp:Label`), for [`SidecarData::resolve_label`].
|
||||
pub label_text: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
@@ -121,11 +123,14 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
}
|
||||
if let Some(l) = &m.label {
|
||||
out.label = Some(ColorLabel::parse(l.trim()));
|
||||
out.label_text = Some(l.trim().to_string());
|
||||
}
|
||||
let full = d.lc_settings.as_deref().and_then(|j| serde_json::from_str::<Value>(j).ok()).and_then(|v| DevelopSettings::from_json(&v).ok());
|
||||
out.develop = match full {
|
||||
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => Some(DevelopPatch::Partial(crate::crs::to_partial(&d.properties, Some(raw)))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => {
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(out)
|
||||
@@ -164,7 +169,13 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
}
|
||||
let develop = match &sc.develop {
|
||||
Some(DevelopPatch::Full(s)) => (**s).clone(),
|
||||
Some(DevelopPatch::Partial(v)) => lightcraft_develop::apply_partial(&p.develop, v, 1.0),
|
||||
Some(DevelopPatch::Partial(v)) => {
|
||||
let mut v = v.clone();
|
||||
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);
|
||||
}
|
||||
lightcraft_develop::apply_partial(&p.develop, &v, 1.0)
|
||||
}
|
||||
None => return false,
|
||||
};
|
||||
if develop == *p.develop {
|
||||
@@ -175,12 +186,20 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn label_name(l: ColorLabel) -> String {
|
||||
format!("{l:?}")
|
||||
impl SidecarData {
|
||||
/// 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 {
|
||||
if let Some(t) = &self.label_text {
|
||||
self.label = Some(if t.is_empty() { None } else { cat.label_from_name(t) });
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidecar packet for a photo.
|
||||
pub fn sidecar_packet(p: &Photo) -> String {
|
||||
/// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom
|
||||
/// label names included, as other editors do).
|
||||
pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
let meta = lightcraft_meta::Metadata {
|
||||
software: Some("LightCraft".into()),
|
||||
@@ -196,7 +215,7 @@ pub fn sidecar_packet(p: &Photo) -> String {
|
||||
artist: nz(&p.meta.creator),
|
||||
keywords: p.meta.keywords.clone(),
|
||||
rating: Some(p.rating.min(5) as i8),
|
||||
label: p.label.map(label_name),
|
||||
label: p.label.map(|l| cat.label_name(l)),
|
||||
capture_time: p.captured.as_deref().and_then(lightcraft_meta::DateTime::parse_iso),
|
||||
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
|
||||
..Default::default()
|
||||
@@ -267,7 +286,7 @@ impl Session {
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
let path = sidecar_path(orig, self.xmp.naming);
|
||||
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
write_atomic(&path, sidecar_packet(p, &self.catalog).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -276,7 +295,9 @@ 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.xmp.naming) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
let mut q = (**p).clone();
|
||||
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
|
||||
let mut ops = vec![
|
||||
@@ -344,7 +365,7 @@ mod tests {
|
||||
#[test]
|
||||
fn packet_roundtrip_restores_everything() {
|
||||
let p = photo();
|
||||
let x = sidecar_packet(&p);
|
||||
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
|
||||
let sc = parse_sidecar(&x, false).unwrap();
|
||||
assert_eq!(sc.rating, Some(4));
|
||||
assert_eq!(sc.flag, Some(Flag::Pick));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB)
|
||||
//! kept in the library, so photos stay editable — and exportable at proxy size — while their
|
||||
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::Photo;
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
const MAGIC: &[u8] = b"LCSP1\n";
|
||||
|
||||
/// The proxy's file name for a photo (by its content, so copies share one).
|
||||
pub fn file_name(p: &Photo) -> String {
|
||||
let h = lightcraft_preview::Hasher128::new().str(&crate::media::content_key(p)).finish();
|
||||
format!("{:032x}.lcsp", h.0)
|
||||
}
|
||||
|
||||
/// Encode a source image as a smart preview.
|
||||
pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
|
||||
let scale = if lum.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
let k = ((lum.len() as f64) * 0.9995) as usize;
|
||||
let k = k.min(lum.len() - 1);
|
||||
let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp);
|
||||
v.max(1.0)
|
||||
};
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let rgba = Rgba8 { width: img.width, height: img.height, data };
|
||||
let jpg = lightcraft_codecs::encode_jpeg(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
|
||||
92,
|
||||
lightcraft_codecs::ChromaSubsampling::S444,
|
||||
&Default::default(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = MAGIC.to_vec();
|
||||
out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes());
|
||||
out.extend_from_slice(&jpg);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode a smart preview back into a source image.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, String> {
|
||||
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
|
||||
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
|
||||
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
|
||||
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
|
||||
// the decoder undoes the sRGB encoding; the values are the source's own primaries
|
||||
let mut img = d.image;
|
||||
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Where a library keeps its smart previews.
|
||||
pub fn dir(library: &Path) -> PathBuf {
|
||||
library.join("Smart Previews")
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
decode(&b).map(Arc::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
let back = decode(&encode(&img).unwrap()).unwrap();
|
||||
assert_eq!((back.width, back.height), (img.width, img.height));
|
||||
let mut err = 0.0f64;
|
||||
for (a, b) in img.data.iter().zip(&back.data) {
|
||||
for k in 0..3 {
|
||||
let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0)));
|
||||
err += ((x - y) as f64).abs();
|
||||
}
|
||||
}
|
||||
let mean = err / (img.data.len() * 3) as f64;
|
||||
assert!(mean < 0.02, "mean encoded error {mean}");
|
||||
assert!(decode(b"nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -381,3 +381,325 @@ fn paste_from_previous_and_copy_paste_metadata() {
|
||||
s.execute("photo.pasteMetadata", &json!({"ids": [ids[2].0]})).unwrap();
|
||||
assert_eq!(s.catalog.photo(ids[2]).unwrap().meta.creator, "Me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_move_and_split() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
|
||||
s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "active": ids[0].0})).unwrap();
|
||||
s.execute("stack.group", &json!({"collapsed": false})).unwrap();
|
||||
let order = |s: &Session| s.catalog.stack_of(ids[0]).or(s.catalog.stack_of(ids[4])).map(|st| st.photos.clone()).unwrap_or_default();
|
||||
let before = order(&s);
|
||||
assert_eq!(before.len(), 5);
|
||||
s.execute("stack.moveUp", &json!({"id": before[2].0})).unwrap();
|
||||
assert_eq!(order(&s)[1], before[2]);
|
||||
s.execute("stack.moveDown", &json!({"id": before[2].0})).unwrap();
|
||||
assert_eq!(order(&s), before);
|
||||
// split at the 4th photo: 3 stay, 2 form a new stack
|
||||
s.execute("stack.split", &json!({"id": before[3].0})).unwrap();
|
||||
assert_eq!(s.catalog.stack_of(before[0]).unwrap().photos, before[..3].to_vec());
|
||||
assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos, before[3..].to_vec());
|
||||
// splitting at the last photo of a 2-stack leaves both unstacked
|
||||
s.execute("stack.split", &json!({"id": before[4].0})).unwrap();
|
||||
assert!(s.catalog.stack_of(before[3]).is_none() && s.catalog.stack_of(before[4]).is_none());
|
||||
assert!(s.execute("stack.split", &json!({"id": before[0].0})).is_err(), "the top can't split");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_presets_save_and_apply() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("filter.savePreset", &json!({"name": "Empty"})).is_err(), "nothing to save");
|
||||
s.execute("library.filter", &json!({"rating": 4})).unwrap();
|
||||
let four = s.visible_cloned().len();
|
||||
s.execute("filter.savePreset", &json!({"name": "Best"})).unwrap();
|
||||
s.execute("library.clearFilter", &json!({})).unwrap();
|
||||
assert!(s.visible_cloned().len() > four);
|
||||
let r = s.execute("filter.applyPreset", &json!({"name": "best"})).unwrap();
|
||||
assert_eq!(r["photos"].as_u64(), Some(four as u64));
|
||||
s.execute("filter.deletePreset", &json!({"name": "Best"})).unwrap();
|
||||
assert!(s.filter_presets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_bw_mix_separates_colours() {
|
||||
let mut s = demo();
|
||||
s.execute("develop.autoBwMix", &json!({})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.treatment, lightcraft_develop::Treatment::Bw);
|
||||
let m = d.bw_mix.bands();
|
||||
assert!(m.iter().any(|v| *v != 0.0), "some band moved: {m:?}");
|
||||
assert!(m.iter().all(|v| v.abs() <= 60.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_ne!(active_dev(&s).treatment, lightcraft_develop::Treatment::Bw, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sync_carries_only_the_changed_settings() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
// the second photo has its own contrast, which must survive
|
||||
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.contrast", "value": 25})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
s.selection.active = Some(PhotoId(ids[0]));
|
||||
assert_eq!(s.execute("develop.autoSync", &json!({})).unwrap(), json!({"on": true}));
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap();
|
||||
for id in &ids {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().light.exposure, 0.7, "photo {id}");
|
||||
}
|
||||
assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.contrast, 25.0, "untouched settings stay");
|
||||
// a slider drag syncs once, on release
|
||||
let shadows: Vec<f64> = ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().light.shadows).collect();
|
||||
s.begin_interaction("Shadows").unwrap();
|
||||
let mut d = (*s.develop_of(PhotoId(ids[0])).unwrap()).clone();
|
||||
d.light.shadows = 40.0;
|
||||
s.set_develop(PhotoId(ids[0]), d, "Shadows").unwrap();
|
||||
assert_eq!(s.develop_of(PhotoId(ids[2])).unwrap().light.shadows, shadows[2], "not while dragging");
|
||||
s.end_interaction().unwrap();
|
||||
assert_eq!(s.develop_of(PhotoId(ids[2])).unwrap().light.shadows, 40.0);
|
||||
// one undo step reverts every photo
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().light.shadows).collect::<Vec<_>>(), shadows);
|
||||
// spot removal belongs to one photo
|
||||
let mut d = (*s.develop_of(PhotoId(ids[0])).unwrap()).clone();
|
||||
d.spots.push(lightcraft_develop::Spot::default());
|
||||
s.set_develop(PhotoId(ids[0]), d, "Remove").unwrap();
|
||||
assert!(s.develop_of(PhotoId(ids[1])).unwrap().spots.is_empty());
|
||||
// off: only the active photo
|
||||
s.execute("develop.autoSync", &json!({"on": false})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1})).unwrap();
|
||||
assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_delta_keeps_only_changes() {
|
||||
let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1});
|
||||
let b = json!({"light": {"exposure": 1, "contrast": 5}, "masks": [1, 2], "x": 1});
|
||||
assert_eq!(crate::json_delta(&a, &b), Some(json!({"light": {"exposure": 1}, "masks": [1, 2]})));
|
||||
assert_eq!(crate::json_delta(&a, &a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_previews_fills_the_cache() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
let cached = |s: &mut Session, id: u64| {
|
||||
let t = s.thumb_job(PhotoId(id), 512).unwrap();
|
||||
let (c, k) = t.cache.clone().unwrap();
|
||||
let q = s.quick_view_job(PhotoId(id), 1024, true).unwrap();
|
||||
let (vc, vk) = q.cached[0].clone();
|
||||
(c.get(k).is_some(), vc.get(vk).is_some())
|
||||
};
|
||||
assert_eq!(cached(&mut s, ids[0]), (false, false));
|
||||
let r = s.execute("library.buildPreviews", &json!({"ids": [ids[0], ids[1]], "size": "standard", "edge": 512, "wait": true})).unwrap();
|
||||
assert_eq!((r["total"].as_u64(), r["done"].as_u64(), r["failed"].as_u64(), r["running"].as_bool()), (Some(2), Some(2), Some(0), Some(false)));
|
||||
// view previews are written to the cache in the background
|
||||
let t0 = std::time::Instant::now();
|
||||
while cached(&mut s, ids[1]) != (true, true) && t0.elapsed().as_secs() < 20 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
assert_eq!(cached(&mut s, ids[0]), (true, true), "thumbnail and loupe view are ready");
|
||||
assert_eq!(cached(&mut s, ids[2]), (false, false), "only the photos asked for");
|
||||
// background build: returns at once, progress by command
|
||||
let r = s.execute("library.buildPreviews", &json!({"ids": [ids[2]], "edge": 512})).unwrap();
|
||||
assert_eq!(r["total"], 1);
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
let p = s.execute("library.previewProgress", &json!({})).unwrap();
|
||||
if p["running"] == false {
|
||||
assert_eq!(p["done"], 1);
|
||||
break;
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 60, "build finishes");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err());
|
||||
}
|
||||
|
||||
/// Colour range: a click samples the colour under it, so the mask selects that colour (white
|
||||
/// in the mask view) and not a different one; ⇧ adds samples, up to five.
|
||||
#[test]
|
||||
fn color_range_sampling_selects_the_clicked_colour() {
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
let mut s = demo();
|
||||
// a flower: magenta petals at the centre, green around them
|
||||
let id = s.catalog.photos().find(|p| p.meta.keywords.iter().any(|k| k == "flower")).unwrap().id;
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
|
||||
let mask = |s: &mut Session| {
|
||||
let m = s.develop_of(id).unwrap().masks[0].clone();
|
||||
let job = s.render_job(id, 200, 200, false, true).unwrap().with_overlay(Overlay::Mask {
|
||||
id: m.id as u16,
|
||||
view: MaskView::WhiteOnBlack,
|
||||
color: [255, 0, 0],
|
||||
opacity: 100,
|
||||
});
|
||||
job.run().rendered.unwrap().image
|
||||
};
|
||||
let at =
|
||||
|img: &lightcraft_raster::Rgba8, x: f64, y: f64| img.data[(y * img.height as f64) as usize * img.width + (x * img.width as f64) as usize][0];
|
||||
let r = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap();
|
||||
assert_eq!(r["samples"].as_array().unwrap().len(), 1);
|
||||
let img = mask(&mut s);
|
||||
let (centre, corner) = (at(&img, 0.5, 0.5), at(&img, 0.03, 0.03));
|
||||
assert!(centre > 200 && corner < centre / 2, "the sampled colour is selected: centre {centre}, corner {corner}");
|
||||
// ⇧-click adds, at most five
|
||||
for _ in 0..6 {
|
||||
s.execute("mask.sampleColor", &json!({"x": 0.03, "y": 0.03, "add": true})).unwrap();
|
||||
}
|
||||
let n = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.52, "add": true})).unwrap()["samples"].as_array().unwrap().len();
|
||||
assert_eq!(n, 5);
|
||||
assert!(at(&mask(&mut s), 0.03, 0.03) > 200, "the added colour is selected too");
|
||||
// a plain click starts over
|
||||
assert_eq!(s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap()["samples"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("mask.sampleColor", &json!({"x": 1.5, "y": 0.5})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_an_edited_photo_keeps_an_auto_version() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
|
||||
let before = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
|
||||
let undo = s.undo.len();
|
||||
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
|
||||
let v = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.clone();
|
||||
assert_eq!(v.len(), before + 1);
|
||||
assert!(v.last().unwrap().auto && v.last().unwrap().settings.light.exposure == 0.4);
|
||||
assert_eq!(s.undo.len(), undo, "not an undo step");
|
||||
// back and away again without changes: nothing new
|
||||
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
|
||||
assert_eq!(s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len(), before + 1);
|
||||
// at most AUTO_VERSIONS
|
||||
for i in 0..(crate::AUTO_VERSIONS + 5) {
|
||||
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.01 * i as f64 + 0.5})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
|
||||
}
|
||||
let autos = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.iter().filter(|v| v.auto).count();
|
||||
assert_eq!(autos, crate::AUTO_VERSIONS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_total_exposures() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
// reference 1/100 f/4 ISO 100, slider +0.5; other 1/50 f/4 ISO 100 (one stop more light)
|
||||
for (i, (sh, ap, iso)) in [("1/100", 4.0f32, 100u32), ("1/50", 4.0, 100), ("", 4.0, 100)].iter().enumerate() {
|
||||
let mut m = s.catalog.photo(PhotoId(ids[i])).unwrap().meta.clone();
|
||||
m.shutter = sh.to_string();
|
||||
m.aperture = Some(*ap);
|
||||
m.iso = Some(*iso);
|
||||
s.commit("t", lightcraft_catalog::Op::SetMeta { id: PhotoId(ids[i]), meta: Box::new(m) }).unwrap();
|
||||
}
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
s.selection.active = Some(PhotoId(ids[0]));
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap();
|
||||
let r = s.execute("develop.matchExposure", &json!({})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["skipped"].clone()), (Some(1), json!([ids[2]])));
|
||||
let e = s.develop_of(PhotoId(ids[1])).unwrap().light.exposure;
|
||||
assert!((e - (-0.5)).abs() < 1e-9, "one stop more light → one stop less exposure: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_similar_filters_to_look_alikes() {
|
||||
let mut s = demo();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": 0.5})).unwrap();
|
||||
let hits: Vec<u64> = r["photos"].as_array().unwrap().iter().map(|h| h["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(hits[0], id.0, "the photo itself is the closest");
|
||||
assert!(hits.len() < s.catalog.len(), "not everything looks alike");
|
||||
let vis = s.visible_cloned();
|
||||
assert!(!vis.is_empty() && vis.iter().all(|v| hits.contains(&v.0)), "the view shows only them");
|
||||
s.execute("library.clearFilter", &json!({})).unwrap();
|
||||
assert!(s.visible_cloned().len() > vis.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_develop_adds_to_each_photo() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
let before: Vec<f64> = ids.iter().map(|i| s.develop_of(PhotoId(*i)).unwrap().light.exposure).collect();
|
||||
s.execute("develop.quickAdjust", &json!({"control": "light.exposure", "delta": 1.0 / 3.0})).unwrap();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let e = s.develop_of(PhotoId(*id)).unwrap().light.exposure;
|
||||
assert!((e - before[i] - 1.0 / 3.0).abs() < 0.02, "{e} vs {}", before[i]);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(PhotoId(ids[0])).unwrap().light.exposure, before[0], "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_components_invert_duplicate_rename_change_mode_and_delete() {
|
||||
use lightcraft_develop::MaskOp;
|
||||
let mut s = demo();
|
||||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||||
s.execute("mask.addComponent", &json!({"op": "subtract", "kind": "linear"})).unwrap();
|
||||
let mask = |s: &Session| active_dev(s).masks[0].clone();
|
||||
let id = mask(&s).id;
|
||||
s.execute("mask.component", &json!({"component": 1, "action": "invert"})).unwrap();
|
||||
assert!(mask(&s).components[1].invert);
|
||||
s.execute("mask.component", &json!({"component": 1, "action": "op", "op": "intersect"})).unwrap();
|
||||
assert_eq!(mask(&s).components[1].op, MaskOp::Intersect);
|
||||
s.execute("mask.component", &json!({"component": 0, "action": "rename", "name": " Face "})).unwrap();
|
||||
assert_eq!(mask(&s).components[0].name.as_deref(), Some("Face"));
|
||||
s.execute("mask.component", &json!({"component": 0, "action": "duplicate"})).unwrap();
|
||||
assert_eq!(mask(&s).components.len(), 3);
|
||||
assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face"));
|
||||
// a cleared name falls back to the kind; one undo step each
|
||||
s.execute("mask.component", &json!({"component": 1, "action": "rename", "name": ""})).unwrap();
|
||||
assert_eq!(mask(&s).components[1].name, None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face"));
|
||||
assert!(s.execute("mask.component", &json!({"component": 9, "action": "invert"})).is_err());
|
||||
assert!(s.execute("mask.component", &json!({"component": 0, "action": "explode"})).is_err());
|
||||
// deleting the last component deletes the mask
|
||||
for _ in 0..3 {
|
||||
s.execute("mask.component", &json!({"id": id, "component": 0, "action": "delete"})).unwrap();
|
||||
}
|
||||
assert!(active_dev(&s).masks.is_empty());
|
||||
assert_eq!(s.active_mask, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_reset_by_channel_and_whole() {
|
||||
let mut s = demo();
|
||||
let s_curve = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]];
|
||||
for ch in ["master", "red", "green", "blue"] {
|
||||
s.execute("develop.curve", &json!({"channel": ch, "points": s_curve})).unwrap();
|
||||
}
|
||||
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 30})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "curve.refineSaturation", "value": 50})).unwrap();
|
||||
// one channel
|
||||
s.execute("curve.reset", &json!({"channel": "red"})).unwrap();
|
||||
let c = active_dev(&s).curve;
|
||||
assert!(c.red.is_empty() && c.green.len() == 4 && c.master.len() == 4, "only red resets");
|
||||
assert_eq!(c.shadows, 30.0);
|
||||
// parametric only
|
||||
s.execute("curve.reset", &json!({"channel": "parametric"})).unwrap();
|
||||
let c = active_dev(&s).curve;
|
||||
assert_eq!(c.shadows, 0.0);
|
||||
assert_eq!(c.master.len(), 4, "point curves stay");
|
||||
// all point curves, then undo restores them in one step
|
||||
s.execute("curve.reset", &json!({"channel": "point"})).unwrap();
|
||||
let c = active_dev(&s).curve;
|
||||
assert!(c.master.is_empty() && c.green.is_empty() && c.blue.is_empty());
|
||||
assert_eq!(c.refine_saturation, 50.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(active_dev(&s).curve.master.len(), 4);
|
||||
// everything (the default)
|
||||
s.execute("curve.reset", &json!({})).unwrap();
|
||||
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
|
||||
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
|
||||
}
|
||||
@@ -166,6 +166,9 @@ fn missing_files_are_found_and_relinked() {
|
||||
std::fs::rename(dir.join("old/b.png"), dir.join("moved/deeper/b.png")).unwrap();
|
||||
let m = s.execute("library.missing", &serde_json::json!({})).unwrap();
|
||||
assert_eq!(m.as_array().map(Vec::len), Some(2));
|
||||
s.execute("library.source", &serde_json::json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(s.visible_cloned().len(), 2, "the Missing Photos source lists them");
|
||||
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
||||
// one by hand
|
||||
let id = m[0]["id"].as_u64().unwrap();
|
||||
let name = std::path::Path::new(m[0]["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().to_string();
|
||||
@@ -182,3 +185,339 @@ fn missing_files_are_found_and_relinked() {
|
||||
assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_added_covers_recent_imports_newest_first() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-recent-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = |name: &str, seed: u8| {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
||||
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(name), b).unwrap();
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
};
|
||||
let mut s = Session::new().with_fs();
|
||||
// three imports: 60 days ago, 10 days ago, today
|
||||
for (when, name, seed) in [("2026-08-01T09:00:00", "old.png", 1), ("2026-09-20T09:00:00", "mid.png", 2), ("2026-09-30T09:00:00", "new.png", 3)] {
|
||||
let path = png(name, seed);
|
||||
let w = when.to_string();
|
||||
s.clock = Box::new(move || w.clone());
|
||||
s.execute("library.import", &serde_json::json!({"paths": [path]})).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, ["new.png", "mid.png"], "the last 30 days, newest import first");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
|
||||
/// import order), and a metadata preset on every photo.
|
||||
#[test]
|
||||
fn copy_with_destination_organize_rename_and_metadata_preset() {
|
||||
let src = temp_dir("orgsrc");
|
||||
let dest = temp_dir("orgdest");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Sam"}})).unwrap();
|
||||
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "metadataPreset": "Nope"})).is_err());
|
||||
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "organize": "weekly"})).is_err());
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat",
|
||||
"rename": "Shoot-{seq:3}", "renameStart": 7, "metadataPreset": "Studio"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 2, "{r}");
|
||||
let mut names: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["Shoot-007.png", "Shoot-008.png"], "catalogued under the new names");
|
||||
assert!(dest.join("Shoot-007.png").exists() && dest.join("Shoot-008.png").exists(), "flat: straight into the destination");
|
||||
assert!(s.catalog.photos().all(|p| p.meta.copyright == "© Studio" && p.meta.creator == "Sam"));
|
||||
// by month, no library needed when a destination is given
|
||||
let dest2 = temp_dir("orgdest2");
|
||||
write_png(&src.join("c.png"), 3);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [src.join("c.png").to_string_lossy()], "mode": "copy", "destination": dest2.to_string_lossy(), "organize": "month"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
let p = s.catalog.photos().find(|p| p.file_name == "c.png").unwrap();
|
||||
let lightcraft_catalog::Source::File { path } = &p.source else { panic!() };
|
||||
let rel = Path::new(path).strip_prefix(&dest2).unwrap();
|
||||
assert_eq!(rel.components().count(), 3, "YYYY/YYYY-MM/c.png: {rel:?}");
|
||||
assert_eq!(rel.parent().unwrap().file_name().unwrap().len(), 7);
|
||||
for d in [&src, &dest, &dest2] {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto import: files in the watched folder are added once their size held between two scans,
|
||||
/// into the named album; non-photos are tried once; the selection stays put.
|
||||
#[test]
|
||||
fn auto_import_watched_folder() {
|
||||
let dir = temp_dir("watch");
|
||||
let mut s = Session::new().with_fs();
|
||||
assert!(s.execute("library.autoImport", &json!({"folder": dir.join("nope").to_string_lossy()})).is_err());
|
||||
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "album": "Tethered"})).unwrap();
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
write_png(&dir.join("one.png"), 1);
|
||||
std::fs::write(dir.join("notes.txt"), "not a photo").unwrap();
|
||||
// first sight: wait (it may still be copying)
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
let r = s.execute("library.autoImportScan", &json!({})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
|
||||
let album = s.catalog.albums().find(|a| a.name == "Tethered").expect("album").id;
|
||||
assert_eq!(s.catalog.album_count(album), 1);
|
||||
assert!(s.selection.ids.is_empty(), "arrivals don't take the selection");
|
||||
// nothing new: nothing happens, and the text file isn't retried
|
||||
for _ in 0..2 {
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
}
|
||||
write_png(&dir.join("two.png"), 2);
|
||||
s.execute("library.autoImportScan", &json!({})).unwrap();
|
||||
s.execute("library.autoImportScan", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.album_count(album), 2);
|
||||
s.execute("library.autoImport", &json!({"folder": null})).unwrap();
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["folder"], json!(null));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart previews: with the original offline the photo still renders (and edits apply) from
|
||||
/// its proxy; without the proxy it can't be opened.
|
||||
#[test]
|
||||
fn smart_previews_stand_in_for_offline_originals() {
|
||||
let src = temp_dir("smartsrc");
|
||||
let lib = temp_dir("smartlib");
|
||||
write_png(&src.join("a.png"), 7);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(r["built"], 1, "{r}");
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap(), json!({"smartPreview": true, "originalOnline": true}));
|
||||
let online = s.render_now(id, 48, 32).unwrap().image;
|
||||
// the drive goes away
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
s.media.forget(id);
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["originalOnline"], false);
|
||||
let offline = s.render_now(id, 48, 32).expect("renders from the smart preview").image;
|
||||
let diff: f64 = online.data.iter().zip(&offline.data).map(|(a, b)| (a[0] as f64 - b[0] as f64).abs()).sum::<f64>() / online.data.len() as f64;
|
||||
assert!(diff < 6.0, "the proxy looks like the original: {diff}");
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
s.media.forget(id);
|
||||
assert_ne!(s.render_now(id, 48, 32).unwrap().image.data, offline.data, "edits apply offline");
|
||||
// without the proxy it can't be opened
|
||||
s.execute("library.smartPreviews", &json!({"discard": true})).unwrap();
|
||||
s.media.forget(id);
|
||||
assert!(s.render_now(id, 48, 32).is_err());
|
||||
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// LUT profiles: a .cube imports as a creative profile (into the library), renders differently
|
||||
/// (Amount 0 = no change), is listed in the profile browser and comes back with the library.
|
||||
#[test]
|
||||
fn cube_luts_become_profiles() {
|
||||
let src = temp_dir("cubesrc");
|
||||
let lib = temp_dir("cubelib");
|
||||
// a warm look: red up, blue down
|
||||
let n = 9;
|
||||
let mut cube = String::from("TITLE \"Warm Test\"\nLUT_3D_SIZE 9\n");
|
||||
for b in 0..n {
|
||||
for g in 0..n {
|
||||
for r in 0..n {
|
||||
let f = |v: usize| v as f32 / (n - 1) as f32;
|
||||
cube.push_str(&format!("{} {} {}\n", (f(r) * 1.15).min(1.0), f(g), f(b) * 0.8));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(src.join("Film Looks")).unwrap();
|
||||
std::fs::write(src.join("Film Looks/warm.cube"), &cube).unwrap();
|
||||
std::fs::write(src.join("Film Looks/broken.cube"), "LUT_3D_SIZE 4\n0 0 0\n").unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let r = s.execute("profile.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!((r["imported"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (1, 1), "{r}");
|
||||
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
|
||||
assert_eq!((r["imported"][0]["name"].as_str(), r["imported"][0]["group"].as_str()), (Some("Warm Test"), Some("Film Looks")));
|
||||
let menu = s.profile_menu();
|
||||
assert!(menu["groups"].as_array().unwrap().iter().any(|g| g["name"] == "Film Looks"), "{menu}");
|
||||
let photo = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
|
||||
let base = s.render_now(photo, 48, 32).unwrap().image;
|
||||
s.execute("develop.profile", &json!({"id": id, "amount": 100})).unwrap();
|
||||
let warm = s.render_now(photo, 48, 32).unwrap().image;
|
||||
let mean = |img: &lightcraft_raster::Rgba8, k: usize| img.data.iter().map(|p| p[k] as f64).sum::<f64>() / img.data.len() as f64;
|
||||
assert!(
|
||||
mean(&warm, 0) > mean(&base, 0) && mean(&warm, 2) < mean(&base, 2),
|
||||
"warmer: R {} → {}, B {} → {}",
|
||||
mean(&base, 0),
|
||||
mean(&warm, 0),
|
||||
mean(&base, 2),
|
||||
mean(&warm, 2)
|
||||
);
|
||||
s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap();
|
||||
assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was");
|
||||
// the library keeps it
|
||||
drop(s);
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.lut_profiles.iter().any(|p| p.id == id));
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
|
||||
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_none());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and
|
||||
/// still render; clashes and moving into itself are refused.
|
||||
#[test]
|
||||
fn folders_rename_and_move_with_their_photos() {
|
||||
let root = temp_dir("folders");
|
||||
let a = root.join("Trip");
|
||||
write_png(&a.join("one.png"), 3);
|
||||
std::fs::write(a.join("one.xmp"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
|
||||
std::fs::create_dir_all(root.join("Taken")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Taken"})).is_err(), "name taken");
|
||||
assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "a/b"})).is_err());
|
||||
let r = s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy 2026"})).unwrap();
|
||||
assert_eq!(r["relinked"], 1);
|
||||
let b = root.join("Italy 2026");
|
||||
assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists());
|
||||
let path = |s: &Session| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
s.media.forget(id);
|
||||
assert!(s.render_now(id, 16, 16).is_ok());
|
||||
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
|
||||
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"], 1);
|
||||
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
|
||||
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
|
||||
#[test]
|
||||
fn assisted_culling_groups_bursts_and_scores_focus() {
|
||||
let dir = temp_dir("cull");
|
||||
let scene = lightcraft_scenes::demo_library()[0].render(480, 320);
|
||||
let soften = |img: &lightcraft_raster::Rgb32f, r: usize| {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let mut out = img.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (mut acc, mut n) = ([0f32; 3], 0.0);
|
||||
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
|
||||
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
|
||||
let c = img.data[yy * w + xx];
|
||||
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
out.data[y * w + x] = acc.map(|v| v / n);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let save = |img: &lightcraft_raster::Rgb32f, name: &str| {
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let png = lightcraft_codecs::encode_png(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(name), png).unwrap();
|
||||
};
|
||||
save(&soften(&scene, 3), "a_soft.png");
|
||||
save(&scene, "b_sharp.png");
|
||||
save(&soften(&scene, 6), "c_softer.png");
|
||||
save(&lightcraft_scenes::demo_library()[7].render(480, 320), "d_other.png");
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||||
for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() {
|
||||
let id = by_name(&s, n);
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap();
|
||||
}
|
||||
let other = by_name(&s, "d_other.png");
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap();
|
||||
let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap();
|
||||
assert_eq!(r["groups"], 1, "{r}");
|
||||
let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap();
|
||||
let (soft, sharp, softer, odd) = (a(&s, "a_soft.png"), a(&s, "b_sharp.png"), a(&s, "c_softer.png"), a(&s, "d_other.png"));
|
||||
eprintln!("sharpness: sharp {} soft {} softer {} other {}", sharp.sharpness, soft.sharpness, softer.sharpness, odd.sharpness);
|
||||
assert!(sharp.sharpness > soft.sharpness && soft.sharpness > softer.sharpness);
|
||||
assert!(sharp.best && !soft.best && sharp.group.is_some() && sharp.group == softer.group);
|
||||
assert!(odd.group.is_none(), "an unrelated photo isn't in the burst");
|
||||
assert_eq!(s.catalog.photo(by_name(&s, "b_sharp.png")).unwrap().flag, lightcraft_catalog::Flag::Pick);
|
||||
// reject the blurry ones (by score), as a rule too
|
||||
let cut = (soft.sharpness + sharp.sharpness) / 2.0;
|
||||
s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": cut})).unwrap();
|
||||
assert_eq!(s.catalog.photo(by_name(&s, "c_softer.png")).unwrap().flag, lightcraft_catalog::Flag::Reject);
|
||||
s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "bestOfGroup", "op": "is", "value": true}]}})).unwrap();
|
||||
let vis = s.visible_cloned();
|
||||
assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
|
||||
#[test]
|
||||
fn dust_spots_are_found_and_healed() {
|
||||
let dir = temp_dir("dust");
|
||||
let (w, h) = (900usize, 600usize);
|
||||
let spots = [(180.0f32, 140.0f32), (600.0, 300.0)];
|
||||
let data: Vec<[u8; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let mut v = 0.5 + 0.3 * (y / h as f32);
|
||||
for (cx, cy) in spots {
|
||||
v -= 0.07 * (-((x - cx).powi(2) + (y - cy).powi(2)) / (2.0 * 25.0)).exp();
|
||||
}
|
||||
let b = (v * 255.0) as u8;
|
||||
[b, b, (b as f32 * 1.15).min(255.0) as u8, 255]
|
||||
})
|
||||
.collect();
|
||||
let png = lightcraft_codecs::encode_png(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: w, height: h, data }),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("sky.png"), png).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.join("sky.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let before = s.render_now(id, 900, 600).unwrap().image;
|
||||
let r = s.execute("spot.findDust", &json!({})).unwrap();
|
||||
assert_eq!(r["added"], 2, "{r}");
|
||||
let after = s.render_now(id, 900, 600).unwrap().image;
|
||||
let at = |img: &lightcraft_raster::Rgba8, x: usize, y: usize| img.data[y * 900 + x][0] as i32;
|
||||
for (cx, cy) in spots {
|
||||
assert!(at(&after, cx as usize, cy as usize) > at(&before, cx as usize, cy as usize) + 6, "the spot at {cx},{cy} is lifted");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -315,3 +315,89 @@ fn import_review_preview_and_options() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Label sets: built-ins, the one in use, saving (persisted with the library's prefs) and the
|
||||
/// names written to / read from XMP.
|
||||
#[test]
|
||||
fn label_sets_and_xmp_label_names() {
|
||||
use lightcraft_catalog::ColorLabel;
|
||||
let dir = std::env::temp_dir().join(format!("lc-labelsets-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
let sets = s.execute("label.sets", &json!({})).unwrap();
|
||||
assert_eq!(sets["current"], "Colors");
|
||||
s.execute("label.applySet", &json!({"name": "review"})).unwrap();
|
||||
assert_eq!(s.catalog.label_name(ColorLabel::Green), "Approved");
|
||||
assert_eq!(s.execute("label.sets", &json!({})).unwrap()["current"], "Review");
|
||||
// tweak one name, save as a user set; built-in names are refused
|
||||
s.execute("label.setNames", &json!({"names": {"purple": "Client"}})).unwrap();
|
||||
assert_eq!(s.execute("label.sets", &json!({})).unwrap()["current"], json!(null));
|
||||
assert!(s.execute("label.saveSet", &json!({"name": "Colors"})).is_err());
|
||||
s.execute("label.saveSet", &json!({"name": "Studio"})).unwrap();
|
||||
s.execute("label.applySet", &json!({"name": "Colors"})).unwrap();
|
||||
assert_eq!(s.catalog.custom_label_name(ColorLabel::Green), None);
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
s.execute("label.applySet", &json!({"name": "studio"})).unwrap();
|
||||
assert_eq!(s.catalog.label_name(ColorLabel::Purple), "Client");
|
||||
// XMP: the label's name is written, and read back through the names
|
||||
let mut p = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
lightcraft_catalog::Source::Demo { scene: 0 },
|
||||
"a.jpg",
|
||||
"JPEG",
|
||||
4,
|
||||
4,
|
||||
"2026-01-01T00:00:00",
|
||||
);
|
||||
p.label = Some(ColorLabel::Green);
|
||||
let x = crate::sidecar::sidecar_packet(&p, &s.catalog);
|
||||
assert!(x.contains("Approved"), "{x}");
|
||||
let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Green)));
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work");
|
||||
s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap();
|
||||
assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Keyword sets: Recent Keywords fill as keywords are added; ⌥N toggles keyword N on the
|
||||
/// selection (added unless every photo has it) without reshuffling the numbers; user sets.
|
||||
#[test]
|
||||
fn keyword_sets_and_recent_keywords() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"addKeywords": ["beach", "sunset"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"addKeywords": ["family"]})).unwrap();
|
||||
let sets = s.execute("keyword.sets", &json!({})).unwrap();
|
||||
assert_eq!(sets["current"], "Recent Keywords");
|
||||
assert_eq!(sets["keywords"], json!(["family", "beach", "sunset"]), "newest first");
|
||||
// ⌥3 on both photos: added to the one without it, so both have it
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap();
|
||||
assert_eq!((r["keyword"].as_str(), r["added"].as_bool()), (Some("sunset"), Some(true)));
|
||||
let has = |s: &Session, id: u64, k: &str| s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.iter().any(|x| x == k);
|
||||
assert!(has(&s, ids[0], "sunset") && has(&s, ids[1], "sunset"));
|
||||
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["keywords"][2], "sunset", "the numbers stay put");
|
||||
// again: everyone has it, so it's removed
|
||||
let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap();
|
||||
assert_eq!(r["added"], false);
|
||||
assert!(!has(&s, ids[0], "sunset") && !has(&s, ids[1], "sunset"));
|
||||
assert_eq!(s.execute("keyword.toggleFromSet", &json!({"index": 9})).unwrap()["changed"], 0, "an empty slot does nothing");
|
||||
assert!(s.execute("keyword.toggleFromSet", &json!({"index": 10})).is_err());
|
||||
// a saved set becomes current; deleting it falls back to Recent Keywords
|
||||
let r = s.execute("keyword.saveSet", &json!({"name": "Wedding", "keywords": ["ceremony", "rings", "first dance"]})).unwrap();
|
||||
assert_eq!((r["current"].as_str(), r["keywords"][1].as_str()), (Some("Wedding"), Some("rings")));
|
||||
s.execute("keyword.toggleFromSet", &json!({"index": 2})).unwrap();
|
||||
assert!(has(&s, ids[1], "rings"));
|
||||
assert!(s.execute("keyword.saveSet", &json!({"name": "Recent Keywords"})).is_err(), "reserved");
|
||||
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "wedding"})).unwrap();
|
||||
assert_eq!(s.keyword_set.as_deref(), Some("Wedding"));
|
||||
s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap();
|
||||
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords");
|
||||
}
|
||||
@@ -152,3 +152,170 @@ fn profile_favorites_and_recent_survive_reopen() {
|
||||
assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates.
|
||||
#[test]
|
||||
fn smart_album_rule_sets() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
s.clock = Box::new(|| "2026-10-02T12:00:00".to_string());
|
||||
let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap();
|
||||
assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3));
|
||||
let bad = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
|
||||
);
|
||||
assert!(bad.is_err(), "operators are checked");
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Good ones", "rules": {"ruleSet": {"match": "all", "rules": [
|
||||
{"field": "rating", "op": "gte", "value": 4},
|
||||
{"group": {"match": "none", "rules": [{"field": "flag", "op": "is", "value": "reject"}]}}
|
||||
]}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = lightcraft_catalog::AlbumId(r["id"].as_u64().unwrap());
|
||||
let want = s.catalog.photos().filter(|p| !p.deleted && p.rating >= 4 && p.flag != lightcraft_catalog::Flag::Reject).count();
|
||||
assert!(want > 0);
|
||||
assert_eq!(s.catalog.album_count(id), want);
|
||||
// live: a new 5-star photo joins
|
||||
let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id;
|
||||
s.execute("photo.rate", &serde_json::json!({"ids": [other.0], "rating": 5})).unwrap();
|
||||
assert_eq!(s.catalog.album_count(id), want + 1);
|
||||
// the same rules work as a library filter (agents: library.filter {ruleSet})
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "is", "value": 5}]}})).unwrap();
|
||||
assert!(s.visible_cloned().iter().all(|id| s.catalog.photo(*id).unwrap().rating == 5));
|
||||
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
let a = list.as_array().unwrap().iter().find(|a| a["name"] == "Good ones").unwrap().clone();
|
||||
assert!(a.to_string().contains("rating is ≥ 4"), "{a}");
|
||||
}
|
||||
|
||||
/// The filter bar's label multi-select: any of the chosen labels.
|
||||
#[test]
|
||||
fn filter_any_of_several_labels() {
|
||||
use lightcraft_catalog::ColorLabel;
|
||||
let mut s = crate::Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
for (id, l) in ids.iter().zip(["red", "yellow", "green"]) {
|
||||
s.execute("photo.label", &serde_json::json!({"ids": [id], "label": l})).unwrap();
|
||||
}
|
||||
s.execute("library.filter", &serde_json::json!({"labels": ["red", "yellow"]})).unwrap();
|
||||
let vis = s.visible_cloned();
|
||||
let want = s.catalog.photos().filter(|p| p.in_library() && matches!(p.label, Some(ColorLabel::Red | ColorLabel::Yellow))).count();
|
||||
assert_eq!(want, 2);
|
||||
assert_eq!(vis.len(), want);
|
||||
assert!(vis.iter().all(|id| matches!(s.catalog.photo(*id).unwrap().label, Some(ColorLabel::Red | ColorLabel::Yellow))));
|
||||
assert!(s.filter.describe().contains("label red or yellow"));
|
||||
s.execute("library.filter", &serde_json::json!({"labels": []})).unwrap();
|
||||
assert!(s.visible_cloned().len() > want);
|
||||
}
|
||||
|
||||
/// Quick Collection / target album: B toggles the selection in it (created on first use);
|
||||
/// another album can be the target; clear empties it; all undoable.
|
||||
#[test]
|
||||
fn quick_collection_and_target_album() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
assert!(s.catalog.quick_collection().is_none());
|
||||
s.execute("library.select", &serde_json::json!({"ids": [ids[0], ids[1]]})).unwrap();
|
||||
let r = s.execute("album.toggleTarget", &serde_json::json!({})).unwrap();
|
||||
assert_eq!((r["added"].as_bool(), r["count"].as_u64(), r["name"].as_str()), (Some(true), Some(2), Some("Quick Collection")));
|
||||
let quick = s.catalog.quick_collection().expect("created");
|
||||
// again: both are in it, so they come out
|
||||
assert_eq!(s.execute("album.toggleTarget", &serde_json::json!({})).unwrap()["count"], 0);
|
||||
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
assert_eq!(s.catalog.album_count(quick), 2);
|
||||
// a regular album as the target
|
||||
let alb = s.execute("album.create", &serde_json::json!({"name": "Picks"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.setTarget", &serde_json::json!({"id": alb})).unwrap();
|
||||
s.execute("album.toggleTarget", &serde_json::json!({"ids": [ids[2]]})).unwrap();
|
||||
assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 1);
|
||||
assert_eq!(s.catalog.album_count(quick), 2, "the Quick Collection is untouched");
|
||||
let smart = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(s.execute("album.setTarget", &serde_json::json!({"id": smart})).is_err());
|
||||
s.execute("album.setTarget", &serde_json::json!({"id": null})).unwrap();
|
||||
s.execute("album.clearQuick", &serde_json::json!({})).unwrap();
|
||||
assert_eq!(s.catalog.album_count(quick), 0);
|
||||
}
|
||||
|
||||
/// Scaling check (ignored: `cargo test --release -p lightcraft-engine -- --ignored scale --nocapture`):
|
||||
/// the per-frame / per-click library queries on a 100k-photo catalog.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scale_100k_library_queries() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use std::time::Instant;
|
||||
let mut s = crate::Session::new();
|
||||
let n = 100_000u64;
|
||||
let mut ops = Vec::with_capacity(n as usize);
|
||||
for i in 0..n {
|
||||
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}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
|
||||
p.rating = (i % 6) as u8;
|
||||
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
|
||||
p.meta.camera = format!("Model {}", i % 30);
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
let t = Instant::now();
|
||||
s.commit("Add", Op::Batch { ops }).unwrap();
|
||||
eprintln!("add 100k: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
let time = |what: &str, f: &mut dyn FnMut()| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
let ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
eprintln!("{what}: {ms:.1} ms");
|
||||
ms
|
||||
};
|
||||
let mut worst: Vec<(String, f64)> = Vec::new();
|
||||
let mut m = |w: &str, f: &mut dyn FnMut()| worst.push((w.to_string(), time(w, f)));
|
||||
m("visible (all)", &mut || {
|
||||
let _ = s.visible_cloned();
|
||||
});
|
||||
m("visible (cached)", &mut || {
|
||||
let _ = s.visible_cloned();
|
||||
});
|
||||
m("filter rating ≥ 4", &mut || {
|
||||
s.execute("library.filter", &serde_json::json!({"rating": 4})).unwrap();
|
||||
let _ = s.visible_cloned();
|
||||
});
|
||||
m("filter ruleSet keyword + date", &mut || {
|
||||
s.execute("library.filter", &serde_json::json!({"rating": 0, "ruleSet": {"rules": [{"field": "keywords", "op": "contains", "value": "kw42"}, {"field": "captureDate", "op": "is", "value": "2015"}]}})).unwrap();
|
||||
let _ = s.visible_cloned();
|
||||
});
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap();
|
||||
m("date groups", &mut || {
|
||||
let _ = s.execute("library.groups", &serde_json::json!({})).unwrap();
|
||||
});
|
||||
m("keyword tree", &mut || {
|
||||
let _ = s.execute("keyword.list", &serde_json::json!({})).unwrap();
|
||||
});
|
||||
m("keyword suggestions", &mut || {
|
||||
let _ = s.catalog.keyword_suggestions(&["kw1".to_string()], "kw", 12);
|
||||
});
|
||||
let alb = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 5}})).unwrap()["id"].as_u64().unwrap();
|
||||
m("smart album count", &mut || {
|
||||
let _ = s.catalog.album_count(lightcraft_catalog::AlbumId(alb));
|
||||
});
|
||||
m("albums.list", &mut || {
|
||||
let _ = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
});
|
||||
m("select all", &mut || {
|
||||
s.execute("library.selectAll", &serde_json::json!({})).unwrap();
|
||||
});
|
||||
m("rate 100k selected", &mut || {
|
||||
s.execute("photo.rate", &serde_json::json!({"rating": 3})).unwrap();
|
||||
});
|
||||
m("undo", &mut || {
|
||||
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
});
|
||||
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
eprintln!("slowest: {:?}", &worst[..3]);
|
||||
}
|
||||
@@ -60,6 +60,10 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
|
||||
let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap());
|
||||
assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option");
|
||||
// the type filter finds merge results (and only them)
|
||||
s.execute("library.filter", &json!({"merged": "hdr"})).unwrap();
|
||||
let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect();
|
||||
assert_eq!(found, [id, id2].into_iter().collect());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -176,3 +176,27 @@ fn stacks_group_collapse_and_replay() {
|
||||
assert_eq!(s2.catalog.stacks().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_source_reports_in_library_state_and_persists_the_view() {
|
||||
// An album source made `library.state` panic (serde can't put an integer in an internally
|
||||
// tagged enum) and `view.json` empty, so the view was lost on the next launch.
|
||||
let dir = temp_dir("album-view");
|
||||
let mut s = open(&dir);
|
||||
let id = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
|
||||
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
|
||||
let st = s.execute("library.state", &json!({})).unwrap();
|
||||
assert_eq!(st["source"], json!({"kind": "album", "id": id}));
|
||||
// the reported source is what `library.source` takes
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
s.execute("library.source", &st["source"]).unwrap();
|
||||
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
|
||||
assert_eq!(s.execute("library.state", &json!({})).unwrap()["source"], json!({"kind": "album", "id": id}));
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let s2 = open(&dir);
|
||||
assert_eq!(s2.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
|
||||
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored too");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -163,3 +163,101 @@ fn default_copyright_and_creator_fill_gaps_on_import() {
|
||||
assert_eq!(s.import_defaults.creator, "Me");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_presets_apply_to_photos_and_imports() {
|
||||
let dir = temp_dir("metapresets");
|
||||
let lib = dir.join("lib");
|
||||
png(&dir.join("new.png"));
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Ann", "keywords": ["studio"]}}))
|
||||
.unwrap();
|
||||
assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"iso": "100"}})).is_err());
|
||||
// on import
|
||||
s.execute("library.preferences", &json!({"import": {"metadataPreset": "studio"}})).unwrap();
|
||||
assert!(s.execute("library.preferences", &json!({"import": {"metadataPreset": "nope"}})).is_err());
|
||||
s.execute("library.import", &json!({"paths": [dir.join("new.png").to_string_lossy()]})).unwrap();
|
||||
let p = photo_named(&s, "new.png");
|
||||
assert_eq!((p.meta.copyright.as_str(), p.meta.creator.as_str(), p.meta.keywords.clone()), ("© Studio", "Ann", vec!["studio".to_string()]));
|
||||
let id = p.id;
|
||||
// applied to a photo: text replaces, keywords add (one undo step)
|
||||
s.execute("photo.setMeta", &json!({"ids": [id.0], "keywords": ["mine"], "creator": "Bob"})).unwrap();
|
||||
s.execute("metadata.applyPreset", &json!({"name": "Studio", "ids": [id.0]})).unwrap();
|
||||
let m = &s.catalog.photo(id).unwrap().meta;
|
||||
assert_eq!(m.creator, "Ann");
|
||||
assert_eq!(m.keywords, vec!["mine".to_string(), "studio".to_string()]);
|
||||
// from the active photo, then persisted
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"city": "Lyon"})).unwrap();
|
||||
s.execute("metadata.savePreset", &json!({"name": "Here"})).unwrap();
|
||||
let saved = s.metadata_presets.iter().find(|m| m.name == "Here").unwrap().fields.clone();
|
||||
assert_eq!(saved, json!({"copyright": "© Studio", "creator": "Ann", "city": "Lyon"}));
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.metadata_presets.len(), 2);
|
||||
assert_eq!(s.import_defaults.metadata_preset.as_deref(), Some("studio"));
|
||||
s.execute("metadata.deletePreset", &json!({"name": "STUDIO"})).unwrap();
|
||||
assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_presets_save_apply_export_import_and_persist() {
|
||||
let lib = temp_dir("curves");
|
||||
let file = lib.join("out").join("looks");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let r = s.execute("curve.presets", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Linear", "Medium Contrast", "Strong Contrast"]);
|
||||
assert_eq!(r["current"], "Linear", "a fresh photo has linear point curves");
|
||||
// built-in: an S-curve on the master channel, parametric untouched
|
||||
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 20})).unwrap();
|
||||
s.execute("curve.applyPreset", &json!({"name": "strong contrast"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert_eq!(d.curve.master.len(), 5);
|
||||
assert!(d.curve.master[1].y < 0.25 && d.curve.master[3].y > 0.75, "an S-curve");
|
||||
assert_eq!(d.curve.shadows, 20.0);
|
||||
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Strong Contrast");
|
||||
// save a custom one (with a red channel)
|
||||
s.execute("develop.curve", &json!({"channel": "red", "points": [[0.0, 0.1], [1.0, 0.9]]})).unwrap();
|
||||
assert!(s.execute("curve.savePreset", &json!({"name": "Linear"})).is_err(), "built-in names are reserved");
|
||||
s.execute("curve.savePreset", &json!({"name": "Faded Red"})).unwrap();
|
||||
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Faded Red");
|
||||
// Linear resets every point channel
|
||||
s.execute("curve.applyPreset", &json!({"name": "Linear"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert!(d.curve.master.is_empty() && d.curve.red.is_empty());
|
||||
s.execute("curve.applyPreset", &json!({"name": "Faded Red"})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().curve.red.len(), 2);
|
||||
// export (extension added), delete, re-import
|
||||
let r = s.execute("curve.exportPresets", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
let path = r["path"].as_str().unwrap().to_string();
|
||||
assert!(path.ends_with(".lccurve"));
|
||||
assert!(s.execute("curve.deletePreset", &json!({"name": "Medium Contrast"})).is_err());
|
||||
s.execute("curve.deletePreset", &json!({"name": "faded red"})).unwrap();
|
||||
assert!(s.curve_presets.is_empty());
|
||||
let r = s.execute("curve.importPresets", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Faded Red"]));
|
||||
assert_eq!(s.curve_presets.len(), 1);
|
||||
// a file carrying a built-in name doesn't shadow it; junk fails cleanly
|
||||
let other = lib.join("one.lccurve");
|
||||
std::fs::write(&other, r#"{"name": "Linear", "master": [[0, 0.05], [1, 1]]}"#).unwrap();
|
||||
let junk = lib.join("junk.lccurve");
|
||||
std::fs::write(&junk, "not json").unwrap();
|
||||
let r = s.execute("curve.importPresets", &json!({"paths": [other.to_string_lossy(), junk.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Linear (imported)"]));
|
||||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
// saved with the library preferences
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let names: Vec<String> = s.curve_presets.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -249,3 +249,199 @@ fn demo_photos_have_no_sidecar() {
|
||||
assert_eq!(r["written"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// All Metadata: EXIF rows from the file and its XMP, for a photo on disk.
|
||||
#[test]
|
||||
fn all_metadata_lists_exif_and_xmp() {
|
||||
let dir = temp_dir("allmeta");
|
||||
let path = dir.join("tagged.jpg");
|
||||
let img = lightcraft_raster::Rgba8 { width: 16, height: 8, data: vec![[90, 120, 200, 255]; 128] };
|
||||
let exif = lightcraft_meta::write_exif(&lightcraft_meta::Metadata { make: Some("Maker".into()), iso: Some(800), ..Default::default() });
|
||||
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
|
||||
let jpg = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
|
||||
std::fs::write(&path, jpg).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "A title"})).unwrap();
|
||||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let r = s.execute("photo.allMetadata", &json!({})).unwrap();
|
||||
let exif = r["exif"].as_array().unwrap();
|
||||
assert!(exif.iter().any(|e| e["name"] == "Make" && e["value"] == "Maker"), "{r}");
|
||||
assert!(exif.iter().any(|e| e["name"] == "ISO Speed" && e["value"] == "800"));
|
||||
assert!(r["xmp"].as_array().unwrap().iter().any(|x| x["name"] == "dc:title" && x["value"] == "A title"), "the sidecar's fields: {r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Convert to DNG: a corpus raw (skipped without the corpus) becomes a DNG next to it with the
|
||||
/// photo relinked and its edits kept; undo goes back to the original; non-raws are skipped.
|
||||
#[test]
|
||||
fn convert_raw_to_dng() {
|
||||
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/nef-nikon-d5100-uncompressed.nef");
|
||||
let mut s = Session::new().with_fs();
|
||||
// a non-raw is skipped
|
||||
let dir = temp_dir("todng");
|
||||
let png = dir.join("a.png");
|
||||
write_png(&png, 3);
|
||||
s.execute("library.import", &json!({"paths": [png.to_string_lossy()]})).unwrap();
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1));
|
||||
if !corpus.exists() {
|
||||
eprintln!("skip: {} absent", corpus.display());
|
||||
return;
|
||||
}
|
||||
let nef = dir.join("shot.nef");
|
||||
std::fs::copy(&corpus, &nef).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [nef.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap();
|
||||
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
|
||||
assert_eq!(r["converted"].as_array().unwrap().len(), 1, "{r}");
|
||||
let p = s.catalog.photo(id).unwrap().clone();
|
||||
assert_eq!((p.file_name.as_str(), p.format.as_str()), ("shot.dng", "DNG"));
|
||||
assert!(dir.join("shot.dng").exists() && nef.exists(), "the original is kept");
|
||||
assert_eq!(p.develop.light.exposure, 0.6, "edits kept");
|
||||
let bytes = std::fs::read(dir.join("shot.dng")).unwrap();
|
||||
assert_eq!(lightcraft_raw::probe(&bytes), Some(lightcraft_raw::RawFormat::Dng));
|
||||
assert!(s.render_now(id, 64, 64).is_ok(), "the DNG renders");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().file_name, "shot.nef");
|
||||
// Copy as DNG at import: only the DNG lands in the destination
|
||||
let card = dir.join("card");
|
||||
std::fs::create_dir_all(&card).unwrap();
|
||||
std::fs::copy(&corpus, card.join("DSC_1.NEF")).unwrap();
|
||||
let dest = dir.join("dest");
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat", "dng": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
|
||||
let names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(names, ["DSC_1.dng"]);
|
||||
assert!(card.join("DSC_1.NEF").exists(), "the card is untouched");
|
||||
let id2 = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
assert_eq!(s.catalog.photo(id2).unwrap().format, "DNG");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to
|
||||
/// the original, added and stacked on top of it; a second one doesn't overwrite the first.
|
||||
#[test]
|
||||
fn external_edit_copy_is_stacked() {
|
||||
let dir = temp_dir("extedit");
|
||||
let src = dir.join("beach.png");
|
||||
write_png(&src, 9);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let orig = s.active().unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let r = s.execute("photo.editExternal", &json!({})).unwrap();
|
||||
let path = r["path"].as_str().unwrap().to_string();
|
||||
assert!(path.ends_with("beach-Edit.tif"), "{path}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let d = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(s.active(), Some(new));
|
||||
let st = s.catalog.stack_of(new).expect("stacked");
|
||||
assert_eq!((st.top(), st.photos.contains(&orig)), (new, true));
|
||||
let r2 = s.execute("photo.editExternal", &json!({"colorSpace": "proPhoto"})).unwrap();
|
||||
assert!(r2["path"].as_str().unwrap().ends_with("beach-Edit-Edit.tif") || r2["path"].as_str().unwrap().contains("-2"), "{r2}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the
|
||||
/// cached source is dropped and renders change; unchanged files are left alone.
|
||||
#[test]
|
||||
fn reload_picks_up_changed_files() {
|
||||
let dir = temp_dir("reload");
|
||||
let f = dir.join("x.png");
|
||||
write_png(&f, 10);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let before = s.render_now(id, 32, 32).unwrap().image.data;
|
||||
let key0 = s.thumb_job(id, 128).unwrap().key;
|
||||
assert_eq!(s.execute("photo.reload", &json!({})).unwrap()["reloaded"], json!([]), "unchanged");
|
||||
write_png(&f, 200);
|
||||
let r = s.execute("photo.reload", &json!({})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]));
|
||||
assert_ne!(s.thumb_job(id, 128).unwrap().key, key0, "the UI sees a new render key");
|
||||
assert_ne!(s.render_now(id, 32, 32).unwrap().image.data, before, "renders the new pixels");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_the_file_and_the_edits() {
|
||||
let dir = temp_dir("dup");
|
||||
let f = dir.join("pic.png");
|
||||
write_png(&f, 5);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||||
let orig = s.active().unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.8})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let alb = s.execute("album.create", &json!({"name": "Keep", "addSelected": true})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("photo.duplicate", &json!({})).unwrap();
|
||||
let dup = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap());
|
||||
let d = s.catalog.photo(dup).unwrap().clone();
|
||||
assert_eq!(d.file_name, "pic-copy.png");
|
||||
assert!(dir.join("pic-copy.png").exists());
|
||||
assert_eq!((d.develop.light.exposure, d.rating), (0.8, 4));
|
||||
assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 2);
|
||||
assert_ne!(dup, orig);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.catalog.photo(dup).is_none(), "one undo step");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gps_typed_in_is_saved_to_xmp() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap_or_else(|| s.catalog.photos().next().unwrap().id);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"gps": "48°51'30\"N 2°17'40\"E"})).unwrap();
|
||||
let (la, lo) = s.catalog.photo(id).unwrap().meta.gps.unwrap();
|
||||
assert!((la - 48.8583).abs() < 1e-3 && (lo - 2.2944).abs() < 1e-3, "{la} {lo}");
|
||||
let x = crate::sidecar::sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog);
|
||||
let back = lightcraft_meta::parse_xmp(&x).unwrap().metadata.gps.unwrap();
|
||||
assert!((back.latitude - la).abs() < 1e-5 && (back.longitude - lo).abs() < 1e-5);
|
||||
assert!(s.execute("photo.setMeta", &json!({"gps": "north pole-ish"})).is_err());
|
||||
s.execute("photo.setMeta", &json!({"gps": null})).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().meta.gps.is_none());
|
||||
}
|
||||
|
||||
/// DNG export compression: lossless, zip and none all decode; none is the biggest.
|
||||
#[test]
|
||||
fn dng_export_compression_choices() {
|
||||
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/nef-nikon-d5100-uncompressed.nef");
|
||||
if !corpus.exists() {
|
||||
eprintln!("skip: {} absent", corpus.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let mut sizes = Vec::new();
|
||||
for c in ["lossless", "deflate", "uncompressed"] {
|
||||
let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c}));
|
||||
let mut out = Vec::new();
|
||||
let mut write = |_: &str, b: &[u8]| {
|
||||
out = b.to_vec();
|
||||
Ok(())
|
||||
};
|
||||
crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap();
|
||||
let raw = lightcraft_raw::decode(&out).unwrap_or_else(|e| panic!("{c}: {e}"));
|
||||
assert!(raw.width > 1000, "{c}");
|
||||
sizes.push((c, out.len()));
|
||||
}
|
||||
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The "My Photos" source in the left panel.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
#[serde(tag = "kind", content = "id", rename_all = "camelCase")]
|
||||
pub enum LibrarySource {
|
||||
#[default]
|
||||
All,
|
||||
@@ -17,6 +17,8 @@ pub enum LibrarySource {
|
||||
Picks,
|
||||
/// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not.
|
||||
Folder,
|
||||
/// Photos whose original file can't be found (`library.missing`).
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// The folder a [`LibrarySource::Folder`] view shows.
|
||||
@@ -27,20 +29,27 @@ pub struct Browse {
|
||||
pub subfolders: bool,
|
||||
}
|
||||
|
||||
/// How far back Recently Added reaches from the latest import.
|
||||
pub const RECENT_DAYS: i64 = 30;
|
||||
|
||||
impl LibrarySource {
|
||||
pub fn to_filter(&self, f: &Filter, cat: &Catalog) -> Filter {
|
||||
let mut f = f.clone();
|
||||
match self {
|
||||
LibrarySource::All => {}
|
||||
// everything imported in the 30 days up to the latest import (by import, newest first)
|
||||
LibrarySource::RecentlyAdded => {
|
||||
let latest = cat.photos().map(|p| p.imported.clone()).max().unwrap_or_default();
|
||||
f.imported = Some(latest.get(..10).unwrap_or("").to_string());
|
||||
let latest = cat.photos().filter(|p| p.in_library()).map(|p| p.imported.clone()).max().unwrap_or_default();
|
||||
let from = lightcraft_catalog::stacks::iso_seconds(&latest)
|
||||
.map(|s| lightcraft_catalog::dates::civil(s - RECENT_DAYS * 86_400))
|
||||
.unwrap_or_else(|| latest.get(..10).unwrap_or("").to_string());
|
||||
f.imported_from = Some(from);
|
||||
}
|
||||
LibrarySource::Album(a) => f.album = Some(*a),
|
||||
LibrarySource::RecentlyDeleted => f.deleted = true,
|
||||
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
|
||||
// the folder itself is filled in by the session (it holds the path)
|
||||
LibrarySource::Folder => {}
|
||||
LibrarySource::Folder | LibrarySource::Missing => {}
|
||||
}
|
||||
f
|
||||
}
|
||||
@@ -53,6 +62,7 @@ impl LibrarySource {
|
||||
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
|
||||
LibrarySource::Picks => "Picks".into(),
|
||||
LibrarySource::Folder => "Folder".into(),
|
||||
LibrarySource::Missing => "Missing Photos".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
|
||||
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// the kernel writes 8-bit output: high-bit-depth exports render on the CPU
|
||||
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 {
|
||||
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
|
||||
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
|
||||
return None;
|
||||
}
|
||||
let gpu = device()?;
|
||||
|
||||
@@ -180,6 +180,7 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
s.masks = vec![
|
||||
Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
|
||||
@@ -190,11 +191,13 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
Mask {
|
||||
components: vec![
|
||||
MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false },
|
||||
},
|
||||
MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Intersect,
|
||||
invert: true,
|
||||
shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 1.0) },
|
||||
@@ -223,12 +226,18 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
let erase = BrushStroke { points: vec![Point::new(0.5, 0.4)], size: 0.03, erase: true, ..Default::default() };
|
||||
s.masks = vec![
|
||||
Mask {
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Brush { strokes: vec![stroke, erase] },
|
||||
}],
|
||||
adjust: lightcraft_develop::LocalAdjustments { exposure: 0.7, whites: 20.0, blacks: -20.0, dehaze: 30.0, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::LuminanceRange { lo: 0.5, hi: 0.8, lo_feather: 0.1, hi_feather: 0.1 },
|
||||
@@ -238,6 +247,7 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
},
|
||||
Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::ColorRange { samples: vec![[0.6, -0.05, -0.08]], refine: 50.0 },
|
||||
@@ -252,8 +262,9 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
s.masks = vec![
|
||||
Mask {
|
||||
components: vec![
|
||||
MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Sky },
|
||||
MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky },
|
||||
MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Subtract,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: Point::new(0.7, 0.2), rx: 0.1, ry: 0.1, angle: 0.0, feather: 30.0, invert: false },
|
||||
@@ -263,12 +274,12 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
..Default::default()
|
||||
},
|
||||
Mask {
|
||||
components: vec![MaskComponent { op: MaskOp::Intersect, invert: false, shape: MaskShape::Subject }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Intersect, invert: false, shape: MaskShape::Subject }],
|
||||
adjust: lightcraft_develop::LocalAdjustments { exposure: 0.5, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
Mask {
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape: MaskShape::Background }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape: MaskShape::Background }],
|
||||
adjust: lightcraft_develop::LocalAdjustments { saturation: -60.0, ..Default::default() },
|
||||
invert: true,
|
||||
..Default::default()
|
||||
@@ -278,7 +289,11 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
("masks (local noise, moiré, defringe)", |s| {
|
||||
let radial = MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.3, ry: 0.25, angle: 0.0, feather: 50.0, invert: false };
|
||||
let linear = MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.7) };
|
||||
let m = |shape, adjust| Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() };
|
||||
let m = |shape, adjust| Mask {
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
adjust,
|
||||
..Default::default()
|
||||
};
|
||||
use lightcraft_develop::LocalAdjustments as L;
|
||||
s.masks = vec![
|
||||
m(radial, L { noise: 80.0, moire: 60.0, defringe: 100.0, ..Default::default() }),
|
||||
@@ -301,7 +316,7 @@ fn cases() -> Vec<(&'static str, Edit)> {
|
||||
st(&[(0.5, 0.58)], true, true),
|
||||
];
|
||||
s.masks = vec![Mask {
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
|
||||
adjust: lightcraft_develop::LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
|
||||
..Default::default()
|
||||
}];
|
||||
@@ -452,6 +467,7 @@ fn overlays_match() {
|
||||
Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false },
|
||||
@@ -460,13 +476,14 @@ fn overlays_match() {
|
||||
},
|
||||
Mask {
|
||||
id: 2,
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }],
|
||||
..Default::default()
|
||||
},
|
||||
Mask {
|
||||
id: 3,
|
||||
visible: false,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
|
||||
|
||||
@@ -13,6 +13,7 @@ mod container;
|
||||
mod datetime;
|
||||
mod exif;
|
||||
mod iptc;
|
||||
pub mod tags;
|
||||
mod xmp;
|
||||
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
@@ -20,7 +21,8 @@ pub use datetime::DateTime;
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
|
||||
pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, parse_xmp, write_xmp, write_xmp_lc};
|
||||
pub use tags::{TagRow, file_tag_rows, tag_rows};
|
||||
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info
|
||||
//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications;
|
||||
//! common enumerations are spelled out, everything else is shown as stored. Maker notes and
|
||||
//! binary blobs are summarised by size.
|
||||
|
||||
use lightcraft_tiff::{Ifd, Tiff, Value};
|
||||
|
||||
/// One metadata row.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TagRow {
|
||||
/// `TIFF`, `EXIF`, `GPS`, `Interop`.
|
||||
pub group: &'static str,
|
||||
pub tag: u16,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
const NAMES: &[(u16, &str)] = &[
|
||||
// TIFF / IFD0
|
||||
(0x00fe, "Subfile Type"),
|
||||
(0x0100, "Image Width"),
|
||||
(0x0101, "Image Height"),
|
||||
(0x0102, "Bits Per Sample"),
|
||||
(0x0103, "Compression"),
|
||||
(0x0106, "Photometric Interpretation"),
|
||||
(0x010e, "Image Description"),
|
||||
(0x010f, "Make"),
|
||||
(0x0110, "Model"),
|
||||
(0x0112, "Orientation"),
|
||||
(0x0115, "Samples Per Pixel"),
|
||||
(0x0116, "Rows Per Strip"),
|
||||
(0x0214, "Reference Black/White"),
|
||||
(0x9216, "TIFF/EP Standard ID"),
|
||||
(0x828d, "CFA Repeat Pattern Dim"),
|
||||
(0x828e, "CFA Pattern"),
|
||||
(0xa302, "CFA Pattern"),
|
||||
(0x9217, "Sensing Method"),
|
||||
(0xc4a5, "Print Image Matching"),
|
||||
(0xa500, "Gamma"),
|
||||
(0x011a, "X Resolution"),
|
||||
(0x011b, "Y Resolution"),
|
||||
(0x011c, "Planar Configuration"),
|
||||
(0x0128, "Resolution Unit"),
|
||||
(0x0131, "Software"),
|
||||
(0x0132, "Date/Time Modified"),
|
||||
(0x013b, "Artist"),
|
||||
(0x013e, "White Point"),
|
||||
(0x013f, "Primary Chromaticities"),
|
||||
(0x0213, "YCbCr Positioning"),
|
||||
(0x8298, "Copyright"),
|
||||
(0xc612, "DNG Version"),
|
||||
(0xc614, "Unique Camera Model"),
|
||||
(0xc62f, "Camera Serial Number"),
|
||||
// EXIF
|
||||
(0x829a, "Exposure Time"),
|
||||
(0x829d, "F-Number"),
|
||||
(0x8822, "Exposure Program"),
|
||||
(0x8824, "Spectral Sensitivity"),
|
||||
(0x8827, "ISO Speed"),
|
||||
(0x8830, "Sensitivity Type"),
|
||||
(0x8832, "Recommended Exposure Index"),
|
||||
(0x9000, "Exif Version"),
|
||||
(0x9003, "Date/Time Original"),
|
||||
(0x9004, "Date/Time Digitized"),
|
||||
(0x9010, "Offset Time"),
|
||||
(0x9011, "Offset Time Original"),
|
||||
(0x9012, "Offset Time Digitized"),
|
||||
(0x9101, "Components Configuration"),
|
||||
(0x9102, "Compressed Bits Per Pixel"),
|
||||
(0x9201, "Shutter Speed Value"),
|
||||
(0x9202, "Aperture Value"),
|
||||
(0x9203, "Brightness Value"),
|
||||
(0x9204, "Exposure Compensation"),
|
||||
(0x9205, "Max Aperture Value"),
|
||||
(0x9206, "Subject Distance"),
|
||||
(0x9207, "Metering Mode"),
|
||||
(0x9208, "Light Source"),
|
||||
(0x9209, "Flash"),
|
||||
(0x920a, "Focal Length"),
|
||||
(0x9214, "Subject Area"),
|
||||
(0x927c, "Maker Note"),
|
||||
(0x9286, "User Comment"),
|
||||
(0x9290, "Sub-second Time"),
|
||||
(0x9291, "Sub-second Time Original"),
|
||||
(0x9292, "Sub-second Time Digitized"),
|
||||
(0xa000, "FlashPix Version"),
|
||||
(0xa001, "Color Space"),
|
||||
(0xa002, "Pixel X Dimension"),
|
||||
(0xa003, "Pixel Y Dimension"),
|
||||
(0xa20e, "Focal Plane X Resolution"),
|
||||
(0xa20f, "Focal Plane Y Resolution"),
|
||||
(0xa210, "Focal Plane Resolution Unit"),
|
||||
(0xa215, "Exposure Index"),
|
||||
(0xa217, "Sensing Method"),
|
||||
(0xa300, "File Source"),
|
||||
(0xa301, "Scene Type"),
|
||||
(0xa401, "Custom Rendered"),
|
||||
(0xa402, "Exposure Mode"),
|
||||
(0xa403, "White Balance"),
|
||||
(0xa404, "Digital Zoom Ratio"),
|
||||
(0xa405, "Focal Length in 35mm Film"),
|
||||
(0xa406, "Scene Capture Type"),
|
||||
(0xa407, "Gain Control"),
|
||||
(0xa408, "Contrast"),
|
||||
(0xa409, "Saturation"),
|
||||
(0xa40a, "Sharpness"),
|
||||
(0xa40c, "Subject Distance Range"),
|
||||
(0xa420, "Image Unique ID"),
|
||||
(0xa430, "Camera Owner Name"),
|
||||
(0xa431, "Body Serial Number"),
|
||||
(0xa432, "Lens Specification"),
|
||||
(0xa433, "Lens Make"),
|
||||
(0xa434, "Lens Model"),
|
||||
(0xa435, "Lens Serial Number"),
|
||||
// GPS
|
||||
(0x0000, "GPS Version"),
|
||||
(0x0001, "GPS Latitude Ref"),
|
||||
(0x0002, "GPS Latitude"),
|
||||
(0x0003, "GPS Longitude Ref"),
|
||||
(0x0004, "GPS Longitude"),
|
||||
(0x0005, "GPS Altitude Ref"),
|
||||
(0x0006, "GPS Altitude"),
|
||||
(0x0007, "GPS Time Stamp"),
|
||||
(0x000c, "GPS Speed Ref"),
|
||||
(0x000d, "GPS Speed"),
|
||||
(0x0010, "GPS Img Direction Ref"),
|
||||
(0x0011, "GPS Img Direction"),
|
||||
(0x0012, "GPS Map Datum"),
|
||||
(0x001d, "GPS Date Stamp"),
|
||||
];
|
||||
|
||||
/// GPS tags reuse low numbers, so they are looked up separately.
|
||||
fn name(group: &str, tag: u16) -> String {
|
||||
let gps = group == "GPS";
|
||||
NAMES
|
||||
.iter()
|
||||
.find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f)))
|
||||
.map(|(_, n)| n.to_string())
|
||||
.unwrap_or_else(|| format!("Tag 0x{tag:04X}"))
|
||||
}
|
||||
|
||||
fn rational(n: f64, d: f64) -> String {
|
||||
if d == 0.0 {
|
||||
return "—".into();
|
||||
}
|
||||
let v = n / d;
|
||||
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() }
|
||||
}
|
||||
|
||||
fn list<T: ToString>(v: &[T]) -> String {
|
||||
let shown: Vec<String> = v.iter().take(16).map(|x| x.to_string()).collect();
|
||||
if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") }
|
||||
}
|
||||
|
||||
/// Spelled-out enumerations for the commonest tags.
|
||||
fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> {
|
||||
if group == "GPS" {
|
||||
return None;
|
||||
}
|
||||
Some(match (tag, v) {
|
||||
(0x0112, 1) => "Horizontal (normal)",
|
||||
(0x0112, 3) => "Rotated 180°",
|
||||
(0x0112, 6) => "Rotated 90° CW",
|
||||
(0x0112, 8) => "Rotated 90° CCW",
|
||||
(0x0112, 2) => "Mirrored horizontally",
|
||||
(0x0112, 4) => "Mirrored vertically",
|
||||
(0x0103, 1) => "Uncompressed",
|
||||
(0x0103, 6) => "JPEG (old-style)",
|
||||
(0x0103, 7) => "JPEG",
|
||||
(0x0103, 8) => "Deflate",
|
||||
(0x0103, 32773) => "PackBits",
|
||||
(0x0103, 34892) => "Lossy JPEG",
|
||||
(0x0106, 0) => "WhiteIsZero",
|
||||
(0x0106, 1) => "BlackIsZero",
|
||||
(0x0106, 2) => "RGB",
|
||||
(0x0106, 6) => "YCbCr",
|
||||
(0x0106, 32803) => "Color Filter Array",
|
||||
(0x0106, 34892) => "Linear Raw",
|
||||
(0x011c, 1) => "Chunky",
|
||||
(0x011c, 2) => "Planar",
|
||||
(0x0128, 2) => "inches",
|
||||
(0x0128, 3) => "centimetres",
|
||||
(0x8822, 1) => "Manual",
|
||||
(0x8822, 2) => "Program",
|
||||
(0x8822, 3) => "Aperture priority",
|
||||
(0x8822, 4) => "Shutter priority",
|
||||
(0x9207, 2) => "Center-weighted average",
|
||||
(0x9207, 3) => "Spot",
|
||||
(0x9207, 5) => "Pattern",
|
||||
(0xa001, 1) => "sRGB",
|
||||
(0xa001, 0xffff) => "Uncalibrated",
|
||||
(0xa402, 0) => "Auto",
|
||||
(0xa402, 1) => "Manual",
|
||||
(0xa402, 2) => "Auto bracket",
|
||||
(0xa403, 0) => "Auto",
|
||||
(0xa403, 1) => "Manual",
|
||||
(0xa406, 0) => "Standard",
|
||||
(0xa406, 1) => "Landscape",
|
||||
(0xa406, 2) => "Portrait",
|
||||
(0xa406, 3) => "Night",
|
||||
(0x9209, 0) => "No flash",
|
||||
(0x9209, 1) => "Fired",
|
||||
(0x9209, 5) => "Fired, return not detected",
|
||||
(0x9209, 7) => "Fired, return detected",
|
||||
(0x9209, 9) => "On, fired",
|
||||
(0x9209, 16) => "Off, did not fire",
|
||||
(0x9209, 24) => "Auto, did not fire",
|
||||
(0x9209, 25) => "Auto, fired",
|
||||
(0x9209, 32) => "No flash function",
|
||||
(0x9208, 0) => "Unknown",
|
||||
(0x9208, 1) => "Daylight",
|
||||
(0x9208, 2) => "Fluorescent",
|
||||
(0x9208, 3) => "Tungsten",
|
||||
(0x9208, 4) => "Flash",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn value_text(group: &str, tag: u16, v: &Value) -> String {
|
||||
let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string());
|
||||
match v {
|
||||
Value::Ascii(s) => s.trim().to_string(),
|
||||
Value::Short(a) if a.len() == 1 => single(a[0] as i64),
|
||||
Value::Long(a) if a.len() == 1 => single(a[0] as i64),
|
||||
Value::Short(a) => list(a),
|
||||
Value::Long(a) => list(a),
|
||||
Value::SShort(a) => list(a),
|
||||
Value::SLong(a) => list(a),
|
||||
Value::Long8(a) => list(a),
|
||||
Value::SLong8(a) => list(a),
|
||||
Value::Float(a) => list(a),
|
||||
Value::Double(a) => list(a),
|
||||
Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => {
|
||||
// exposure time as a fraction of a second
|
||||
let (n, d) = a[0];
|
||||
let s = n as f64 / d as f64;
|
||||
if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) }
|
||||
}
|
||||
Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)),
|
||||
Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)),
|
||||
Value::Rational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::<Vec<_>>().join(", "),
|
||||
Value::SRational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::<Vec<_>>().join(", "),
|
||||
Value::Byte(b) | Value::Undefined(b) => {
|
||||
// version tags and short printable values read as text
|
||||
let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0);
|
||||
if printable && !b.is_empty() {
|
||||
String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string()
|
||||
} else if b.len() <= 8 {
|
||||
list(b)
|
||||
} else {
|
||||
format!("({} bytes)", b.len())
|
||||
}
|
||||
}
|
||||
Value::SByte(a) => list(a),
|
||||
Value::Ifd(a) => format!("(IFD at {})", list(a)),
|
||||
Value::Ifd8(a) => format!("(IFD at {})", list(a)),
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec<TagRow>) {
|
||||
for e in &ifd.entries {
|
||||
// pointers to other IFDs and image data are structure, not metadata
|
||||
if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773)
|
||||
&& group != "GPS"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0,
|
||||
/// then the EXIF, GPS and interoperability IFDs.
|
||||
pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ifd0) = tiff.ifds.first() {
|
||||
rows_of("TIFF", ifd0, &mut out);
|
||||
if let Some(e) = &ifd0.exif {
|
||||
rows_of("EXIF", e, &mut out);
|
||||
if let Some(i) = &e.interop {
|
||||
rows_of("Interop", i, &mut out);
|
||||
}
|
||||
}
|
||||
if let Some(g) = &ifd0.gps {
|
||||
rows_of("GPS", g, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block
|
||||
/// (JPEG, PNG, WebP, HEIF…). Empty when there is none.
|
||||
pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
|
||||
if Tiff::sniff(bytes).is_some()
|
||||
&& let Ok(t) = Tiff::parse(bytes)
|
||||
{
|
||||
return tag_rows(&t);
|
||||
}
|
||||
let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() };
|
||||
Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_from_written_exif() {
|
||||
let m = crate::Metadata {
|
||||
make: Some("Maker".into()),
|
||||
model: Some("Model X".into()),
|
||||
exposure_time: Some(1.0 / 250.0),
|
||||
f_number: Some(2.8),
|
||||
iso: Some(400),
|
||||
focal_length: Some(35.0),
|
||||
gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let exif = crate::write_exif(&m);
|
||||
let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap();
|
||||
let rows = tag_rows(&t);
|
||||
let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone());
|
||||
assert_eq!(get("Make").as_deref(), Some("Maker"));
|
||||
assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s"));
|
||||
assert_eq!(get("F-Number").as_deref(), Some("f/2.8"));
|
||||
assert_eq!(get("Focal Length").as_deref(), Some("35 mm"));
|
||||
assert_eq!(get("ISO Speed").as_deref(), Some("400"));
|
||||
assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}");
|
||||
assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out");
|
||||
}
|
||||
}
|
||||
+66
-1
@@ -65,6 +65,67 @@ pub struct XmpData {
|
||||
/// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep
|
||||
/// item order, language alternatives put `x-default` first.
|
||||
pub properties: BTreeMap<String, Vec<String>>,
|
||||
/// Every top-level property with its full structure (arrays of structs such as local
|
||||
/// corrections, which [`XmpData::properties`] flattens).
|
||||
pub values: BTreeMap<String, XmpValue>,
|
||||
}
|
||||
|
||||
/// A structured XMP value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum XmpValue {
|
||||
Text(String),
|
||||
Array(Vec<XmpValue>),
|
||||
Struct(BTreeMap<String, XmpValue>),
|
||||
}
|
||||
|
||||
impl XmpValue {
|
||||
/// A struct field (`prefix:name`).
|
||||
pub fn field(&self, k: &str) -> Option<&XmpValue> {
|
||||
match self {
|
||||
XmpValue::Struct(m) => m.get(k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn text(&self) -> Option<&str> {
|
||||
match self {
|
||||
XmpValue::Text(s) => Some(s.trim()),
|
||||
XmpValue::Array(a) => a.first().and_then(XmpValue::text),
|
||||
XmpValue::Struct(_) => None,
|
||||
}
|
||||
}
|
||||
/// Array items (a lone value is a one-item array).
|
||||
pub fn items(&self) -> &[XmpValue] {
|
||||
match self {
|
||||
XmpValue::Array(a) => a,
|
||||
other => std::slice::from_ref(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_struct(n: &Node) -> XmpValue {
|
||||
let mut m = BTreeMap::new();
|
||||
for (k, v) in &n.attrs {
|
||||
if !is_rdf_meta_attr(k) {
|
||||
m.insert(k.clone(), XmpValue::Text(v.clone()));
|
||||
}
|
||||
}
|
||||
for c in &n.children {
|
||||
m.insert(c.name.clone(), node_value(c));
|
||||
}
|
||||
XmpValue::Struct(m)
|
||||
}
|
||||
|
||||
fn node_value(n: &Node) -> XmpValue {
|
||||
if let Some(arr) = n.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
|
||||
return XmpValue::Array(arr.children.iter().filter(|li| li.name == "rdf:li").map(node_value).collect());
|
||||
}
|
||||
if let Some(d) = n.children.iter().find(|c| c.name == "rdf:Description") {
|
||||
return node_struct(d);
|
||||
}
|
||||
if !n.children.is_empty() || n.attrs.iter().any(|(k, _)| !is_rdf_meta_attr(k)) {
|
||||
return node_struct(n);
|
||||
}
|
||||
XmpValue::Text(n.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| n.text.trim().to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -322,8 +383,12 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
|
||||
let mut descs = Vec::new();
|
||||
find_descriptions(&tree, &mut descs);
|
||||
let mut props = BTreeMap::new();
|
||||
let mut values = BTreeMap::new();
|
||||
for d in descs {
|
||||
collect_description(d, "", &mut props);
|
||||
if let XmpValue::Struct(m) = node_struct(d) {
|
||||
values.extend(m);
|
||||
}
|
||||
}
|
||||
let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let num = |k: &str| first(k).and_then(|s| parse_number(&s));
|
||||
@@ -394,7 +459,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
|
||||
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
|
||||
}
|
||||
let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned();
|
||||
Ok(XmpData { metadata: m, lc_settings, properties: props })
|
||||
Ok(XmpData { metadata: m, lc_settings, properties: props, values })
|
||||
}
|
||||
|
||||
fn frac(v: f64) -> String {
|
||||
|
||||
@@ -58,6 +58,46 @@ pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTo
|
||||
}
|
||||
}
|
||||
|
||||
/// An automatic black & white mix (slider values, red … magenta) for `src` under `s`'s white
|
||||
/// balance and tone: each hue band's colourful pixels are pushed away from the image's mean
|
||||
/// lightness — bands brighter than average get brighter, darker ones darker — so areas that
|
||||
/// differ only in colour stay apart in grey. Bands with almost no colourful pixels stay at 0.
|
||||
pub fn auto_bw_mix(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> [f64; 8] {
|
||||
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
|
||||
let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box);
|
||||
let base = DevelopSettings { wb: s.wb, light: s.light, ..DevelopSettings::default() };
|
||||
crate::local::scene_linear_pre(&mut img, info, &base);
|
||||
let gain = 2f32.powf(base.light.exposure as f32);
|
||||
let (mut mass, mut sum_l) = ([0f64; 8], [0f64; 8]);
|
||||
let (mut all_l, mut n) = (0f64, 0usize);
|
||||
for p in &img.data {
|
||||
let lch = lab_to_lch(oklab_from_2020(p.map(|v| (v * gain).max(0.0))));
|
||||
all_l += lch[0] as f64;
|
||||
n += 1;
|
||||
let k = (lch[1] / 0.2).min(1.0) as f64; // as in the B&W conversion
|
||||
if k < 0.05 {
|
||||
continue;
|
||||
}
|
||||
let w = crate::colorops::band_weights(lch[2]);
|
||||
for i in 0..8 {
|
||||
mass[i] += w[i] as f64 * k;
|
||||
sum_l[i] += w[i] as f64 * k * lch[0] as f64;
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return [0.0; 8];
|
||||
}
|
||||
let mean = all_l / n as f64;
|
||||
let total: f64 = mass.iter().sum();
|
||||
std::array::from_fn(|i| {
|
||||
if total <= 0.0 || mass[i] / total < 0.01 {
|
||||
return 0.0;
|
||||
}
|
||||
let sep = sum_l[i] / mass[i] - mean;
|
||||
(sep * 400.0).clamp(-60.0, 60.0).round()
|
||||
})
|
||||
}
|
||||
|
||||
/// Grey-world white balance weighted towards mid-tone, low-chroma pixels. Returns (temp, tint).
|
||||
pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) {
|
||||
let img = lightcraft_raster::resample::fit(src, 256, 256, lightcraft_raster::resample::Filter::Box);
|
||||
@@ -134,3 +174,20 @@ mod tests {
|
||||
assert!((c[0] - c[2]).abs() < 0.02, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bw_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_bw_mix_pushes_light_and_dark_hues_apart() {
|
||||
// left: a light yellow, right: a dark blue (scene-linear Rec. 2020)
|
||||
let src = Rgb32f::from_fn(64, 32, |x, _| if x < 32 { [0.55, 0.5, 0.05] } else { [0.01, 0.02, 0.12] });
|
||||
let m = auto_bw_mix(&src, &SourceInfo::default(), &DevelopSettings::default());
|
||||
let (yellow, blue) = (m[2], m[5]);
|
||||
assert!(yellow > 0.0 && blue < 0.0, "{m:?}");
|
||||
// an image without colour leaves the mix alone
|
||||
let grey = Rgb32f::from_fn(16, 16, |x, _| [x as f32 / 16.0; 3]);
|
||||
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,27 @@ impl ColorOps {
|
||||
return rgb;
|
||||
}
|
||||
let lab = oklab_from_2020(rgb);
|
||||
// only chroma changes (vibrance / saturation, maybe grading): scale a, b directly — the same
|
||||
// result as the OkLCh round trip without its sin / cos (and atan2 unless vibrance needs the hue)
|
||||
if !self.mixer && self.points.is_empty() && self.bw.is_none() && local_hue == 0.0 {
|
||||
let mut lab = lab;
|
||||
let c0 = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt();
|
||||
let mut c = c0;
|
||||
if self.vibrance != 0.0 {
|
||||
let low = 1.0 - (c / 0.22).clamp(0.0, 1.0);
|
||||
let skin = if self.vibrance > 0.0 { 1.0 - 0.6 * (-(wrap(lab[2].atan2(lab[1]) - self.skin) / 0.35).powi(2)).exp() } else { 1.0 };
|
||||
c *= (1.0 + self.vibrance * low * low * skin * 1.2).max(0.0);
|
||||
}
|
||||
if self.saturation != 0.0 || local_sat != 0.0 {
|
||||
c *= (1.0 + self.saturation + local_sat).max(0.0);
|
||||
}
|
||||
if c0 > 0.0 {
|
||||
let k = c / c0;
|
||||
lab[1] *= k;
|
||||
lab[2] *= k;
|
||||
}
|
||||
return self.grade(lab);
|
||||
}
|
||||
let [mut l, mut c, mut h] = lab_to_lch(lab);
|
||||
if self.mixer {
|
||||
let w = band_weights(h);
|
||||
@@ -223,7 +244,12 @@ impl ColorOps {
|
||||
l = (l + mix * (c / 0.2).min(1.0) * 0.25).max(0.0);
|
||||
c = 0.0;
|
||||
}
|
||||
let mut lab = lch_to_lab([l, c, h]);
|
||||
self.grade(lch_to_lab([l, c, h]))
|
||||
}
|
||||
|
||||
/// Colour grading on OkLab, then back to linear Rec.2020.
|
||||
#[inline]
|
||||
fn grade(&self, mut lab: [f32; 3]) -> [f32; 3] {
|
||||
if let Some((wheels, blending, balance)) = &self.grading {
|
||||
let m = 0.5 - balance * 0.25;
|
||||
let width = 0.15 + blending * 0.5;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Assisted culling measurements on a decoded source (any size; measured on a fixed-size copy so
|
||||
//! scores compare across photos): focus, clipping, and a tiny signature for finding similar
|
||||
//! shots (bursts). Classical image statistics — no learned models.
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
|
||||
/// Long edge the measurements run at.
|
||||
const EDGE: usize = 512;
|
||||
|
||||
/// Display-ish luminance (sRGB-encoded) of `img` resampled to fit `EDGE` (box filter).
|
||||
fn luma(img: &Rgb32f) -> (Vec<f32>, usize, usize) {
|
||||
let (w, h) = (img.width.max(1), img.height.max(1));
|
||||
let s = (EDGE as f64 / w.max(h) as f64).min(1.0);
|
||||
let (ow, oh) = (((w as f64 * s).round() as usize).max(8), ((h as f64 * s).round() as usize).max(8));
|
||||
let mut out = vec![0f32; ow * oh];
|
||||
for (y, row) in out.chunks_mut(ow).enumerate() {
|
||||
let (y0, y1) = (y * h / oh, ((y + 1) * h / oh).max(y * h / oh + 1).min(h));
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let (x0, x1) = (x * w / ow, ((x + 1) * w / ow).max(x * w / ow + 1).min(w));
|
||||
let mut acc = 0.0f32;
|
||||
for yy in y0..y1 {
|
||||
for xx in x0..x1 {
|
||||
let c = img.data[yy * w + xx];
|
||||
acc += 0.2627 * c[0] + 0.678 * c[1] + 0.0593 * c[2];
|
||||
}
|
||||
}
|
||||
let l = acc / ((y1 - y0) * (x1 - x0)) as f32;
|
||||
*v = lightcraft_color::transfer::linear_to_srgb(l.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
(out, ow, oh)
|
||||
}
|
||||
|
||||
/// Focus 0..100: detail energy (Laplacian) of the sharpest areas — the 90th percentile of
|
||||
/// 16 × 16-pixel blocks, so a sharp subject on a soft background still reads as sharp.
|
||||
pub fn sharpness(img: &Rgb32f) -> f32 {
|
||||
let (l, w, h) = luma(img);
|
||||
let b = 16;
|
||||
let mut blocks = Vec::new();
|
||||
for by in (1..h.saturating_sub(1)).step_by(b) {
|
||||
for bx in (1..w.saturating_sub(1)).step_by(b) {
|
||||
let mut e = 0.0f32;
|
||||
let mut n = 0;
|
||||
for y in by..(by + b).min(h - 1) {
|
||||
for x in bx..(bx + b).min(w - 1) {
|
||||
let c = l[y * w + x];
|
||||
let lap = 4.0 * c - l[y * w + x - 1] - l[y * w + x + 1] - l[(y - 1) * w + x] - l[(y + 1) * w + x];
|
||||
e += lap * lap;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
blocks.push(e / n as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let k = (blocks.len() as f32 * 0.9) as usize;
|
||||
let k = k.min(blocks.len() - 1);
|
||||
let (_, v, _) = blocks.select_nth_unstable_by(k, f32::total_cmp);
|
||||
// map energy to 0..100: a crisp, ordinary photo at 512 px reads ≈ 50–80 (calibrated on the
|
||||
// CC0 corpus), soft ones well below
|
||||
100.0 * (1.0 - (-*v / 0.012).exp())
|
||||
}
|
||||
|
||||
/// Share of clipped pixels (crushed blacks + blown highlights), 0..1.
|
||||
pub fn clipped(img: &Rgb32f) -> f32 {
|
||||
let n = img.data.len().max(1);
|
||||
let c = img.data.iter().filter(|p| p.iter().any(|v| *v >= 0.985) || p.iter().all(|v| *v <= 0.002)).count();
|
||||
c as f32 / n as f32
|
||||
}
|
||||
|
||||
/// A tiny look-alike signature: 8 × 8 luminance, normalised to zero mean and unit norm.
|
||||
pub fn signature(img: &Rgb32f) -> [f32; 64] {
|
||||
let (l, w, h) = luma(img);
|
||||
let mut s = [0f32; 64];
|
||||
for (i, v) in s.iter_mut().enumerate() {
|
||||
let (cx, cy) = (i % 8, i / 8);
|
||||
let (x0, x1) = (cx * w / 8, (cx + 1) * w / 8);
|
||||
let (y0, y1) = (cy * h / 8, (cy + 1) * h / 8);
|
||||
let mut acc = 0.0;
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
acc += l[y * w + x];
|
||||
}
|
||||
}
|
||||
*v = acc / ((x1 - x0) * (y1 - y0)).max(1) as f32;
|
||||
}
|
||||
let mean = s.iter().sum::<f32>() / 64.0;
|
||||
s.iter_mut().for_each(|v| *v -= mean);
|
||||
let norm = s.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-6);
|
||||
s.iter_mut().for_each(|v| *v /= norm);
|
||||
s
|
||||
}
|
||||
|
||||
/// Similarity of two signatures: 1 = the same picture, 0 = unrelated.
|
||||
pub fn similarity(a: &[f32; 64], b: &[f32; 64]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>().clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blur(img: &Rgb32f, r: usize) -> Rgb32f {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let mut out = img.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0f32; 3];
|
||||
let mut n = 0.0;
|
||||
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
|
||||
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
|
||||
let c = img.data[yy * w + xx];
|
||||
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
out.data[y * w + x] = acc.map(|v| v / n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blur_lowers_focus_and_signatures_match() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(480, 320);
|
||||
let soft = blur(&img, 4);
|
||||
let (a, b) = (sharpness(&img), sharpness(&soft));
|
||||
assert!(a > b + 10.0, "sharp {a} vs blurred {b}");
|
||||
assert!(similarity(&signature(&img), &signature(&soft)) > 0.95, "the same picture");
|
||||
let other = lightcraft_scenes::demo_library()[5].render(480, 320);
|
||||
assert!(similarity(&signature(&img), &signature(&other)) < 0.9);
|
||||
assert!(clipped(&img) < 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Dust-spot detection: sensor dust shows as small, soft, darker-than-surroundings round spots
|
||||
//! on smooth areas (sky, walls). Classical: a blurred copy gives the surroundings, the darkening
|
||||
//! is compared with the local texture so detail doesn't trigger it, and each candidate blob
|
||||
//! must be small and roughly round.
|
||||
|
||||
use lightcraft_raster::{Plane, Rgba8, blur::gaussian};
|
||||
|
||||
/// A found spot: centre (0..1 of the image's width / height) and radius (fraction of the long
|
||||
/// edge), with how much darker it is (0..1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DustSpot {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub radius: f64,
|
||||
pub strength: f32,
|
||||
}
|
||||
|
||||
/// Dust spots in a rendered image; `sensitivity` 0..100 (higher finds fainter spots).
|
||||
pub fn detect(img: &Rgba8, sensitivity: f32) -> Vec<DustSpot> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w < 32 || h < 32 {
|
||||
return Vec::new();
|
||||
}
|
||||
let long = w.max(h) as f32;
|
||||
let l = Plane::from_fn(w, h, |x, y| {
|
||||
let p = img.data[y * w + x];
|
||||
(0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0
|
||||
});
|
||||
let around = gaussian(&l, (0.012 * long).max(2.0));
|
||||
// texture: local mean of the fine detail (what isn't explained by the surroundings)
|
||||
let detail = Plane::from_fn(w, h, |x, y| (l.data[y * w + x] - around.data[y * w + x]).abs());
|
||||
let texture = gaussian(&detail, (0.02 * long).max(3.0));
|
||||
let min_dark = 0.03 - 0.022 * (sensitivity / 100.0).clamp(0.0, 1.0);
|
||||
let dark: Vec<f32> = (0..w * h).map(|i| (around.data[i] - l.data[i]).max(0.0)).collect();
|
||||
let candidate = |i: usize| dark[i] > min_dark && dark[i] > 3.0 * texture.data[i] && l.data[i] > 0.08;
|
||||
// blobs of candidate pixels (4-connected)
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut out = Vec::new();
|
||||
let (rmin, rmax) = (0.0015 * long, 0.02 * long);
|
||||
for start in 0..w * h {
|
||||
if seen[start] || !candidate(start) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![start];
|
||||
seen[start] = true;
|
||||
let (mut n, mut sx, mut sy, mut peak) = (0usize, 0.0f64, 0.0f64, 0.0f32);
|
||||
let (mut x0, mut x1, mut y0, mut y1) = (w, 0, h, 0);
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
n += 1;
|
||||
sx += x as f64;
|
||||
sy += y as f64;
|
||||
peak = peak.max(dark[i]);
|
||||
(x0, x1, y0, y1) = (x0.min(x), x1.max(x), y0.min(y), y1.max(y));
|
||||
if n > (rmax * rmax * 4.0) as usize {
|
||||
break; // too big to be dust: stop growing
|
||||
}
|
||||
for j in [i.wrapping_sub(1), i + 1, i.wrapping_sub(w), i + w] {
|
||||
let ok = j < w * h && !seen[j] && ((j % w).abs_diff(x) <= 1);
|
||||
if ok && candidate(j) {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (bw, bh) = ((x1 - x0 + 1) as f32, (y1 - y0 + 1) as f32);
|
||||
let r = (bw.max(bh)) / 2.0;
|
||||
let round = bw.max(bh) / bw.min(bh) <= 1.8 && n as f32 >= 0.45 * bw * bh;
|
||||
if r >= rmin && r <= rmax && round {
|
||||
out.push(DustSpot {
|
||||
x: (sx / n as f64 + 0.5) / w as f64,
|
||||
y: (sy / n as f64 + 0.5) / h as f64,
|
||||
radius: (r / long) as f64 * 1.6,
|
||||
strength: peak,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.strength.total_cmp(&a.strength));
|
||||
out.truncate(60);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A smooth sky gradient with three soft dark spots: all found, nothing else.
|
||||
#[test]
|
||||
fn finds_spots_on_smooth_areas() {
|
||||
let (w, h) = (600usize, 400usize);
|
||||
let spots = [(120.0f32, 90.0f32, 4.0f32), (400.0, 200.0, 6.0), (500.0, 330.0, 3.0)];
|
||||
let data: Vec<[u8; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let mut v = 0.55 + 0.25 * (y / h as f32) + 0.05 * (x / w as f32);
|
||||
for (cx, cy, r) in spots {
|
||||
let d2 = (x - cx).powi(2) + (y - cy).powi(2);
|
||||
v -= 0.06 * (-d2 / (2.0 * r * r)).exp();
|
||||
}
|
||||
let b = (v * 255.0) as u8;
|
||||
[b, b, (b as f32 * 1.1).min(255.0) as u8, 255]
|
||||
})
|
||||
.collect();
|
||||
let found = detect(&Rgba8 { width: w, height: h, data }, 50.0);
|
||||
assert_eq!(found.len(), 3, "{found:?}");
|
||||
for (cx, cy, _) in spots {
|
||||
assert!(
|
||||
found.iter().any(|s| (s.x * w as f64 - cx as f64).abs() < 3.0 && (s.y * h as f64 - cy as f64).abs() < 3.0),
|
||||
"{cx},{cy}: {found:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textured_scenes_give_few_false_spots() {
|
||||
let img = lightcraft_scenes::demo_library()[2].render(600, 400);
|
||||
let data = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0) as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let found = detect(&Rgba8 { width: 600, height: 400, data }, 50.0);
|
||||
assert!(found.len() <= 3, "{} false spots", found.len());
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] {
|
||||
/// Everything the per-pixel stage computes once per render: the CPU loop below and the GPU kernel
|
||||
/// (`lightcraft-gpu`) both read their parameters from here, so the two cannot drift apart.
|
||||
pub struct FinishParams {
|
||||
/// A LUT profile and its amount (0..2), applied to the display-encoded colour.
|
||||
pub lut: Option<(std::sync::Arc<crate::lut::Lut3d>, f32)>,
|
||||
pub tone: ToneMap,
|
||||
pub ops: ColorOps,
|
||||
/// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1).
|
||||
@@ -165,6 +167,8 @@ pub struct FinishParams {
|
||||
pub to_out: [[f32; 3]; 3],
|
||||
pub out_luma: [f32; 3],
|
||||
pub out_trc: OutputTrc,
|
||||
/// Soft proofing (CPU only; the GPU path declines proof renders).
|
||||
pub proof: Option<crate::output::ProofParams>,
|
||||
pub hl: f32,
|
||||
pub sh: f32,
|
||||
pub clar: f32,
|
||||
@@ -222,6 +226,7 @@ impl FinishParams {
|
||||
} else {
|
||||
ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks)
|
||||
},
|
||||
lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)),
|
||||
ops: ColorOps::new(s),
|
||||
curves: curve_luts(&s.curve),
|
||||
refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32,
|
||||
@@ -229,6 +234,7 @@ impl FinishParams {
|
||||
to_out: space.from_working(),
|
||||
out_luma: space.luma(),
|
||||
out_trc: space.trc(),
|
||||
proof: None,
|
||||
hl: (s.light.highlights / 100.0) as f32,
|
||||
sh: (s.light.shadows / 100.0) as f32,
|
||||
clar,
|
||||
@@ -251,9 +257,10 @@ impl FinishParams {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 {
|
||||
pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, proof: Option<crate::Proof>) -> Rgba8 {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
fp.proof = proof.map(|pr| pr.params(space));
|
||||
let trc = fp.out_trc;
|
||||
let data = finish_with(p, &fp, false, |e| match trc {
|
||||
OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255],
|
||||
@@ -266,10 +273,20 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
||||
}
|
||||
|
||||
/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries).
|
||||
pub(crate) fn finish_deep(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, depth: OutputDepth) -> DeepImage {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn finish_deep(
|
||||
p: &Prepared,
|
||||
s: &DevelopSettings,
|
||||
frame: &Frame,
|
||||
info: &SourceInfo,
|
||||
space: OutputSpace,
|
||||
depth: OutputDepth,
|
||||
proof: Option<crate::Proof>,
|
||||
) -> DeepImage {
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space);
|
||||
fp.proof = proof.map(|pr| pr.params(space));
|
||||
let trc = fp.out_trc;
|
||||
let samples = match depth {
|
||||
OutputDepth::F32Linear => {
|
||||
@@ -298,6 +315,7 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
store: impl Fn([f32; 3]) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let p_lut = fp.lut.clone();
|
||||
let FinishParams {
|
||||
tone,
|
||||
ops,
|
||||
@@ -503,25 +521,25 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
}
|
||||
}
|
||||
|
||||
// --- gamut map to the output space (desaturate towards luminance until in range)
|
||||
let m = to_out;
|
||||
let mut r = [
|
||||
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
|
||||
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
|
||||
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
|
||||
];
|
||||
let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0);
|
||||
let mut t = 1.0f32;
|
||||
for c in r {
|
||||
if c < 0.0 {
|
||||
t = t.min(yy / (yy - c).max(1e-9));
|
||||
} else if c > 1.0 {
|
||||
t = t.min((1.0 - yy) / (c - yy).max(1e-9));
|
||||
// --- gamut map to the output space (desaturate towards luminance until in range);
|
||||
// soft proofing maps into the proof space first and shows that in the output space
|
||||
let mut warn = None;
|
||||
let mut r = match &fp.proof {
|
||||
Some(pp) => {
|
||||
let q0 = mul3(&pp.to_proof, d);
|
||||
let (q, t) = gamut_map(q0, pp.luma);
|
||||
if pp.dest_warning && out_of_gamut(q0, t) {
|
||||
warn = Some(crate::output::PROOF_DEST_WARNING);
|
||||
}
|
||||
mul3(&pp.proof_to_out, q)
|
||||
}
|
||||
None => mul3(to_out, d),
|
||||
};
|
||||
let (mapped, t) = gamut_map(r, *out_luma);
|
||||
if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) {
|
||||
warn = Some(crate::output::PROOF_DISPLAY_WARNING);
|
||||
}
|
||||
if t < 1.0 {
|
||||
r = r.map(|c| yy + (c - yy) * t);
|
||||
}
|
||||
r = mapped;
|
||||
|
||||
// --- encode, curves, grain
|
||||
let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) };
|
||||
@@ -542,12 +560,56 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum));
|
||||
e = e.map(|v| v + k);
|
||||
}
|
||||
*px = store(e);
|
||||
let e = match &p_lut {
|
||||
Some((l, k)) => {
|
||||
let m = l.apply(e.map(|v| v.clamp(0.0, 1.0)));
|
||||
[e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k]
|
||||
}
|
||||
None => e,
|
||||
};
|
||||
*px = store(warn.unwrap_or(e));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for
|
||||
/// the gamut warnings (tolerates rounding at the gamut boundary).
|
||||
const GAMUT_WARN: f32 = 0.995;
|
||||
|
||||
/// For the gamut warnings: `r` needed desaturating by `t` to fit, and isn't simply a neutral
|
||||
/// beyond white or below black (that's clipping, which the clipping warnings show).
|
||||
#[inline]
|
||||
fn out_of_gamut(r: [f32; 3], t: f32) -> bool {
|
||||
let (lo, hi) = (r[0].min(r[1]).min(r[2]), r[0].max(r[1]).max(r[2]));
|
||||
t < GAMUT_WARN && (hi - lo) > 0.02 * hi.abs().max(1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2],
|
||||
m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2],
|
||||
m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2],
|
||||
]
|
||||
}
|
||||
|
||||
/// Desaturate linear `r` towards its luminance (weights `luma`) until every channel is in 0..1;
|
||||
/// returns the mapped colour and the chroma scale used (1 = already in gamut).
|
||||
#[inline]
|
||||
pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) {
|
||||
let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, 1.0);
|
||||
let mut t = 1.0f32;
|
||||
for c in r {
|
||||
if c < 0.0 {
|
||||
t = t.min(yy / (yy - c).max(1e-9));
|
||||
} else if c > 1.0 {
|
||||
t = t.min((1.0 - yy) / (c - yy).max(1e-9));
|
||||
}
|
||||
}
|
||||
if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) }
|
||||
}
|
||||
|
||||
/// Local Defringe weight of a scene-linear colour `c` whose log luminance differs by `det` from
|
||||
/// its fine blur: high on strong edges with a purple or green cast.
|
||||
#[inline]
|
||||
|
||||
@@ -24,9 +24,12 @@
|
||||
|
||||
pub mod auto;
|
||||
pub mod colorops;
|
||||
pub mod cull;
|
||||
pub mod dust;
|
||||
pub mod finish;
|
||||
pub mod geometry;
|
||||
pub mod local;
|
||||
pub mod lut;
|
||||
pub mod masks;
|
||||
pub mod optics;
|
||||
pub mod output;
|
||||
@@ -38,7 +41,7 @@ pub mod transform;
|
||||
pub mod upright;
|
||||
pub mod visualize;
|
||||
|
||||
pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
|
||||
pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc, Proof};
|
||||
pub use visualize::{MaskView, Overlay};
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Treatment};
|
||||
@@ -90,11 +93,22 @@ pub struct RenderRequest {
|
||||
pub space: OutputSpace,
|
||||
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
|
||||
pub depth: OutputDepth,
|
||||
/// Soft proofing (CPU only; see [`Proof`]).
|
||||
pub proof: Option<Proof>,
|
||||
}
|
||||
|
||||
impl RenderRequest {
|
||||
pub fn fit(max_w: usize, max_h: usize) -> Self {
|
||||
Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 }
|
||||
Self {
|
||||
max_w,
|
||||
max_h,
|
||||
quality: Quality::Full,
|
||||
apply_crop: true,
|
||||
overlay: Overlay::None,
|
||||
space: OutputSpace::Srgb,
|
||||
depth: OutputDepth::U8,
|
||||
proof: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,13 +409,13 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
|
||||
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
|
||||
}
|
||||
if req.depth != OutputDepth::U8 {
|
||||
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth);
|
||||
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof);
|
||||
let image = deep.to_rgba8();
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("finish (deep)", &mut t);
|
||||
return Rendered { image, histogram, deep: Some(deep) };
|
||||
}
|
||||
let image = finish::finish(&prep, s, frame, info, req.space);
|
||||
let image = finish::finish(&prep, s, frame, info, req.space, req.proof);
|
||||
lap("finish", &mut t);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
lap("histogram", &mut t);
|
||||
@@ -411,6 +425,34 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
|
||||
Rendered { image, histogram, deep: None }
|
||||
}
|
||||
|
||||
/// The colour a colour-range mask would sample at normalized image point `p` (OkLab of the
|
||||
/// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares
|
||||
/// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image.
|
||||
pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> {
|
||||
let plan = plan(src, info, s, req);
|
||||
let mut img = plan.frame.sample(src, plan.w, plan.h);
|
||||
lin_cpu(&mut img, info, &plan);
|
||||
let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p);
|
||||
let (cx, cy) = (o.x.floor() as i64, o.y.floor() as i64);
|
||||
if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 {
|
||||
return None;
|
||||
}
|
||||
let gain = (s.light.exposure as f32).exp2();
|
||||
let mut acc = [0f64; 3];
|
||||
let mut n = 0.0;
|
||||
for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) {
|
||||
for x in (cx - 1).max(0)..=(cx + 1).min(plan.w as i64 - 1) {
|
||||
let c = img.data[y as usize * plan.w + x as usize].map(|v| v * gain);
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(masks::tonemap_for_select(c));
|
||||
for k in 0..3 {
|
||||
acc[k] += lab[k] as f64;
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
Some(acc.map(|v| v / n))
|
||||
}
|
||||
|
||||
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
|
||||
/// fresh evaluation.
|
||||
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! 3D LUT profiles: `.cube` files (the plain-text Cube LUT format: `LUT_3D_SIZE`, optional
|
||||
//! `DOMAIN_MIN` / `DOMAIN_MAX`, then size³ "r g b" lines with red changing fastest), applied to
|
||||
//! the display-encoded output colour and blended by the profile amount. Profiles are registered
|
||||
//! here by id (`lut:…`) and found by the finishing stage; LUT profiles render on the CPU.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
|
||||
/// A 3D colour lookup table.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Lut3d {
|
||||
pub size: usize,
|
||||
pub data: Vec<[f32; 3]>,
|
||||
pub domain_min: [f32; 3],
|
||||
pub domain_max: [f32; 3],
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl Lut3d {
|
||||
/// Parse a `.cube` file.
|
||||
pub fn parse_cube(text: &str) -> Result<Lut3d, String> {
|
||||
let (mut size, mut title) = (0usize, None);
|
||||
let (mut min, mut max) = ([0.0f32; 3], [1.0f32; 3]);
|
||||
let mut data = Vec::new();
|
||||
let three = |rest: &str| -> Option<[f32; 3]> {
|
||||
let v: Vec<f32> = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect();
|
||||
(v.len() == 3).then(|| [v[0], v[1], v[2]])
|
||||
};
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if l.is_empty() || l.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = l.strip_prefix("TITLE") {
|
||||
title = Some(r.trim().trim_matches('"').to_string());
|
||||
} else if let Some(r) = l.strip_prefix("LUT_3D_SIZE") {
|
||||
size = r.trim().parse().map_err(|_| format!("bad LUT_3D_SIZE `{}`", r.trim()))?;
|
||||
if !(2..=256).contains(&size) {
|
||||
return Err(format!("LUT_3D_SIZE {size} out of range"));
|
||||
}
|
||||
} else if l.starts_with("LUT_1D_SIZE") {
|
||||
return Err("1D LUTs are not supported (only 3D .cube files)".into());
|
||||
} else if let Some(r) = l.strip_prefix("DOMAIN_MIN") {
|
||||
min = three(r).ok_or("bad DOMAIN_MIN")?;
|
||||
} else if let Some(r) = l.strip_prefix("DOMAIN_MAX") {
|
||||
max = three(r).ok_or("bad DOMAIN_MAX")?;
|
||||
} else if l.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
// other keywords (LUT_IN_VIDEO_RANGE…) are ignored
|
||||
} else {
|
||||
data.push(three(l).ok_or_else(|| format!("bad line `{l}`"))?);
|
||||
}
|
||||
}
|
||||
if size == 0 {
|
||||
return Err("not a 3D .cube LUT (no LUT_3D_SIZE)".into());
|
||||
}
|
||||
if data.len() != size * size * size {
|
||||
return Err(format!("expected {} entries, found {}", size * size * size, data.len()));
|
||||
}
|
||||
Ok(Lut3d { size, data, domain_min: min, domain_max: max, title })
|
||||
}
|
||||
|
||||
/// Look up `c` (trilinear).
|
||||
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||||
let n = self.size;
|
||||
let s = (n - 1) as f32;
|
||||
let mut idx = [0usize; 3];
|
||||
let mut frac = [0.0f32; 3];
|
||||
for k in 0..3 {
|
||||
let span = (self.domain_max[k] - self.domain_min[k]).max(1e-6);
|
||||
let x = (((c[k] - self.domain_min[k]) / span).clamp(0.0, 1.0)) * s;
|
||||
let i = (x.floor() as usize).min(n - 2);
|
||||
idx[k] = i;
|
||||
frac[k] = x - i as f32;
|
||||
}
|
||||
let at = |r: usize, g: usize, b: usize| self.data[r + n * (g + n * b)];
|
||||
let lerp = |a: [f32; 3], b: [f32; 3], t: f32| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
let (r, g, b) = (idx[0], idx[1], idx[2]);
|
||||
let c00 = lerp(at(r, g, b), at(r + 1, g, b), frac[0]);
|
||||
let c10 = lerp(at(r, g + 1, b), at(r + 1, g + 1, b), frac[0]);
|
||||
let c01 = lerp(at(r, g, b + 1), at(r + 1, g, b + 1), frac[0]);
|
||||
let c11 = lerp(at(r, g + 1, b + 1), at(r + 1, g + 1, b + 1), frac[0]);
|
||||
lerp(lerp(c00, c10, frac[1]), lerp(c01, c11, frac[1]), frac[2])
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> &'static RwLock<HashMap<String, Arc<Lut3d>>> {
|
||||
static R: OnceLock<RwLock<HashMap<String, Arc<Lut3d>>>> = OnceLock::new();
|
||||
R.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Make a LUT available to renders under profile id `id` (`lut:…`).
|
||||
pub fn register(id: &str, lut: Lut3d) {
|
||||
registry().write().unwrap_or_else(|e| e.into_inner()).insert(id.to_string(), Arc::new(lut));
|
||||
}
|
||||
|
||||
/// The LUT registered for profile `id`.
|
||||
pub fn get(id: &str) -> Option<Arc<Lut3d>> {
|
||||
if !id.starts_with("lut:") {
|
||||
return None;
|
||||
}
|
||||
registry().read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
|
||||
}
|
||||
|
||||
/// Forget a LUT profile.
|
||||
pub fn unregister(id: &str) {
|
||||
registry().write().unwrap_or_else(|e| e.into_inner()).remove(id);
|
||||
}
|
||||
|
||||
/// Profile ids that render with a LUT (and so on the CPU).
|
||||
pub fn is_lut_profile(id: &str) -> bool {
|
||||
id.starts_with("lut:")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cube(f: impl Fn([f32; 3]) -> [f32; 3], n: usize) -> String {
|
||||
let mut s = format!("TITLE \"test\"\nLUT_3D_SIZE {n}\n");
|
||||
for b in 0..n {
|
||||
for g in 0..n {
|
||||
for r in 0..n {
|
||||
let c = f([r as f32 / (n - 1) as f32, g as f32 / (n - 1) as f32, b as f32 / (n - 1) as f32]);
|
||||
s.push_str(&format!("{} {} {}\n", c[0], c[1], c[2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_swap() {
|
||||
let id = Lut3d::parse_cube(&cube(|c| c, 17)).unwrap();
|
||||
assert_eq!(id.title.as_deref(), Some("test"));
|
||||
let v = id.apply([0.2, 0.55, 0.9]);
|
||||
assert!(v.iter().zip([0.2, 0.55, 0.9]).all(|(a, b)| (a - b).abs() < 1e-5), "{v:?}");
|
||||
let swap = Lut3d::parse_cube(&cube(|c| [c[2], c[1], c[0]], 9)).unwrap();
|
||||
let v = swap.apply([0.1, 0.5, 0.8]);
|
||||
assert!((v[0] - 0.8).abs() < 1e-5 && (v[2] - 0.1).abs() < 1e-5, "{v:?}");
|
||||
assert!(Lut3d::parse_cube("LUT_1D_SIZE 4\n0 0 0").is_err());
|
||||
assert!(Lut3d::parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err(), "too few entries");
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,16 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if m.refine > 0.0 {
|
||||
// Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge,
|
||||
// the width of a soft brush edge or gradient)
|
||||
let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let sigma = (0.04 * w.max(h) as f32 * k).max(1.0);
|
||||
let refined = guided_cross(log_l, &alpha, sigma, 0.02);
|
||||
for (a, r) in alpha.data.iter_mut().zip(&refined.data) {
|
||||
*a += (r - *a) * k.sqrt();
|
||||
}
|
||||
}
|
||||
if m.invert {
|
||||
alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
@@ -175,7 +185,7 @@ fn smooth_plane(p: &mut Plane, sigma: f32) {
|
||||
}
|
||||
|
||||
/// A rough display mapping for colour picking (so samples taken on screen match).
|
||||
fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
|
||||
pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
|
||||
c.map(|v| v / (1.0 + v))
|
||||
}
|
||||
|
||||
@@ -354,7 +364,7 @@ mod tests {
|
||||
let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false };
|
||||
let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0);
|
||||
assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01);
|
||||
let m = Mask { components: vec![MaskComponent { op: MaskOp::Add, invert: true, shape }], ..Default::default() };
|
||||
let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() };
|
||||
let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0);
|
||||
assert!(e[0].alpha.get(50, 50) < 0.01);
|
||||
}
|
||||
@@ -367,7 +377,7 @@ mod tests {
|
||||
let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() };
|
||||
let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() };
|
||||
let m = Mask {
|
||||
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
|
||||
..Default::default()
|
||||
};
|
||||
let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0);
|
||||
@@ -407,4 +417,30 @@ mod tests {
|
||||
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0);
|
||||
assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50));
|
||||
}
|
||||
|
||||
/// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge.
|
||||
#[test]
|
||||
fn refine_edges_follows_the_photo() {
|
||||
let (w, h) = (120usize, 80usize);
|
||||
// left half dark, right half bright
|
||||
let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] });
|
||||
let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() });
|
||||
let f = Frame::new(w, h, &Default::default(), true);
|
||||
let soft = Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) },
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let refined = Mask { refine: 100.0, ..soft.clone() };
|
||||
let step = |m: &Mask| {
|
||||
let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0);
|
||||
a.data[40 * w + 63] - a.data[40 * w + 56]
|
||||
};
|
||||
let (plain, sharp) = (step(&soft), step(&refined));
|
||||
assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}");
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,49 @@ impl OutputSpace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft proofing: render as if the result were converted to `space` (colours outside its gamut
|
||||
/// are mapped into it, then shown in the render's own space), optionally painting what the
|
||||
/// destination can't hold (`dest_warning`, red) and what the display can't show
|
||||
/// (`display_warning`, blue) — Lightroom's destination and monitor gamut warnings.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Proof {
|
||||
pub space: OutputSpace,
|
||||
pub dest_warning: bool,
|
||||
pub display_warning: bool,
|
||||
}
|
||||
|
||||
/// Warning colours (sRGB-encoded): destination gamut red, display gamut blue.
|
||||
pub const PROOF_DEST_WARNING: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
pub const PROOF_DISPLAY_WARNING: [f32; 3] = [0.0, 0.25, 1.0];
|
||||
|
||||
/// [`Proof`] as the per-pixel stage uses it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ProofParams {
|
||||
/// Linear Rec.2020 → linear proof RGB, its luminance weights, linear proof → linear output RGB.
|
||||
pub to_proof: [[f32; 3]; 3],
|
||||
pub luma: [f32; 3],
|
||||
pub proof_to_out: [[f32; 3]; 3],
|
||||
pub dest_warning: bool,
|
||||
pub display_warning: bool,
|
||||
}
|
||||
|
||||
impl Proof {
|
||||
pub fn key(self) -> u64 {
|
||||
(self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9
|
||||
}
|
||||
|
||||
pub fn params(self, out: OutputSpace) -> ProofParams {
|
||||
ProofParams {
|
||||
to_proof: self.space.from_working(),
|
||||
luma: self.space.luma(),
|
||||
proof_to_out: self.space.rgb_space().to_space(&out.rgb_space()).to_f32(),
|
||||
dest_warning: self.dest_warning,
|
||||
display_warning: self.display_warning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample format of a render.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -106,6 +106,7 @@ fn mask_brightens_only_inside() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask {
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 10.0, invert: false },
|
||||
@@ -287,3 +288,45 @@ fn refine_saturation_tames_a_contrast_curve() {
|
||||
let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32;
|
||||
assert!((y(full) - y(refined)).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
|
||||
use crate::{OutputSpace, Proof};
|
||||
let src = scene();
|
||||
let mut s = DevelopSettings::default();
|
||||
s.color.saturation = 100.0;
|
||||
s.color.vibrance = 100.0;
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
let req = |space, proof| RenderRequest { space, proof, ..RenderRequest::fit(120, 120) };
|
||||
let red = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[..3] == [255, 0, 0]).count();
|
||||
let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count();
|
||||
|
||||
// proofing sRGB on an sRGB render changes nothing but the warning
|
||||
let plain = render(&src, &info, &s, &req(OutputSpace::Srgb, None)).image;
|
||||
let same = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image;
|
||||
assert_eq!(plain, same);
|
||||
let warned =
|
||||
render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false }))).image;
|
||||
let n = red(&warned);
|
||||
assert!(n > 0 && n < warned.len(), "{n}");
|
||||
// …and nothing is flagged on an unsaturated edit of a neutral ramp
|
||||
let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]);
|
||||
let g = render(
|
||||
&grey,
|
||||
&info,
|
||||
&DevelopSettings::default(),
|
||||
&RenderRequest { proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true }), ..RenderRequest::fit(64, 8) },
|
||||
);
|
||||
assert_eq!(red(&g.image) + blue(&g.image), 0);
|
||||
|
||||
// a wide-gamut render proofed for sRGB loses the colours sRGB can't hold
|
||||
let wide = render(&src, &info, &s, &req(OutputSpace::DisplayP3, None)).image;
|
||||
let proofed = render(&src, &info, &s, &req(OutputSpace::DisplayP3, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image;
|
||||
assert_ne!(wide, proofed);
|
||||
// proofing a wider space than the display: what the display can't show is flagged blue
|
||||
let pro =
|
||||
render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true })))
|
||||
.image;
|
||||
assert!(blue(&pro) > 0);
|
||||
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
|
||||
}
|
||||
@@ -22,7 +22,7 @@ fn noise(x: usize, y: usize, k: u32) -> f32 {
|
||||
fn left_mask(adjust: LocalAdjustments) -> DevelopSettings {
|
||||
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.46, 0.5) };
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask { id: 1, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
|
||||
s.masks.push(Mask { id: 1, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], adjust, ..Default::default() });
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ mod tests {
|
||||
let info = SourceInfo::default();
|
||||
let shape = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false };
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask { id: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() });
|
||||
s.masks.push(Mask { id: 7, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], ..Default::default() });
|
||||
let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
|
||||
let shot = |s: &DevelopSettings, view: MaskView| {
|
||||
let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 };
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct JobPool<S, R> {
|
||||
shared: Arc<Shared<S, R>>,
|
||||
tx: Sender<Done<S, R>>,
|
||||
rx: Receiver<Done<S, R>>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
threads: usize,
|
||||
started: bool,
|
||||
seq: u64,
|
||||
|
||||
@@ -18,6 +18,9 @@ serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
zune-jpeg = { workspace = true }
|
||||
zune-core = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
jpeg-encoder = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
@@ -57,9 +57,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
if !(1..=4).contains(&cpp) {
|
||||
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
|
||||
}
|
||||
if info.compression == t::compression::LOSSY_JPEG {
|
||||
return Err(RawError::Unsupported("lossy (DCT) JPEG DNG".into()));
|
||||
}
|
||||
if info.compression == t::compression::JPEG_XL {
|
||||
return Err(RawError::Unsupported("JPEG XL DNG".into()));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
|
||||
//! and produces camera → linear Rec.2020 D65 matrices.
|
||||
//!
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Canon CR2, Nikon NEF/NRW (uncompressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! and X-Trans), Panasonic RW2 (packed 12/14-bit), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
|
||||
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon Huffman NEF, Panasonic
|
||||
|
||||
@@ -60,6 +60,13 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or
|
||||
crate::Mode::Full => read_image(data, info, order, packing),
|
||||
crate::Mode::Header => {
|
||||
check_image(data, info)?;
|
||||
// a lossless-JPEG layout the decoder rejects (subsampled components, e.g. Sony's
|
||||
// lossless M/S sizes) must fail here too, as the full decode will
|
||||
if info.compression == 7
|
||||
&& let Some(src) = info.chunks(data.len() as u64).first().and_then(|c| chunk_bytes(data, c))
|
||||
{
|
||||
ljpeg::frame_info(src)?;
|
||||
}
|
||||
Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) })
|
||||
}
|
||||
}
|
||||
@@ -150,6 +157,19 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
||||
v.resize(n, 0);
|
||||
Ok(ChunkPx::U16(v))
|
||||
}
|
||||
comp::LOSSY_JPEG => {
|
||||
// lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples
|
||||
let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?;
|
||||
let mut v = vec![0u16; n];
|
||||
for y in 0..ch.min(jh) {
|
||||
for x in 0..cw.min(jw) {
|
||||
for k in 0..cpp {
|
||||
v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ChunkPx::U16(v))
|
||||
}
|
||||
comp::ADOBE_DEFLATE | comp::DEFLATE => {
|
||||
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
|
||||
let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) };
|
||||
@@ -160,6 +180,24 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
|
||||
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::DecoderOptions;
|
||||
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
|
||||
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs);
|
||||
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts);
|
||||
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
|
||||
let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let c = if cpp == 1 { 1 } else { 3 };
|
||||
if w == 0 || h == 0 || px.len() < w * h * c {
|
||||
return Err(RawError::Corrupt("lossy JPEG tile: short output".into()));
|
||||
}
|
||||
Ok((px, w, h, c))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn unpack_chunk(
|
||||
src: &[u8],
|
||||
@@ -248,3 +286,49 @@ fn unpack_chunk(
|
||||
}
|
||||
Ok(ChunkPx::U16(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lossy_tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::image::Layout;
|
||||
|
||||
/// A lossy-DNG-style image: two 16×8 tiles, each a baseline JPEG, the right one cut short
|
||||
/// by the image edge.
|
||||
#[test]
|
||||
fn lossy_jpeg_tiles_decode() {
|
||||
let (tw, th) = (16usize, 8usize);
|
||||
let tile = |shade: u8| {
|
||||
let px: Vec<u8> = (0..tw * th).flat_map(|i| [shade, (i % tw * 15) as u8, 200]).collect();
|
||||
let mut out = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut out, 100).encode(&px, tw as u16, th as u16, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
out
|
||||
};
|
||||
let (a, b) = (tile(40), tile(220));
|
||||
let mut file = vec![0u8; 8];
|
||||
let oa = file.len() as u64;
|
||||
file.extend_from_slice(&a);
|
||||
let ob = file.len() as u64;
|
||||
file.extend_from_slice(&b);
|
||||
let info = ImageInfo {
|
||||
width: 24,
|
||||
height: 8,
|
||||
bits_per_sample: vec![8, 8, 8],
|
||||
samples_per_pixel: 3,
|
||||
compression: comp::LOSSY_JPEG,
|
||||
photometric: 34892,
|
||||
planar: 1,
|
||||
predictor: 1,
|
||||
sample_format: 1,
|
||||
new_subfile_type: 0,
|
||||
layout: Layout::Tiles { tile_width: tw as u32, tile_height: th as u32 },
|
||||
offsets: vec![oa, ob],
|
||||
byte_counts: vec![a.len() as u64, b.len() as u64],
|
||||
};
|
||||
let RawData::U16(v) = read_image(&file, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") };
|
||||
assert_eq!(v.len(), 24 * 8 * 3);
|
||||
let px = |x: usize, y: usize| &v[(y * 24 + x) * 3..(y * 24 + x) * 3 + 3];
|
||||
assert!((px(2, 3)[0] as i32 - 40).abs() <= 3 && (px(2, 3)[2] as i32 - 200).abs() <= 3, "{:?}", px(2, 3));
|
||||
assert!((px(20, 3)[0] as i32 - 220).abs() <= 3, "second tile: {:?}", px(20, 3));
|
||||
assert!((px(10, 5)[1] as i32 - 150).abs() <= 6, "green ramp: {:?}", px(10, 5));
|
||||
}
|
||||
}
|
||||
Vendored
+130
-4
@@ -14,12 +14,14 @@
|
||||
//! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14
|
||||
//! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits.
|
||||
//!
|
||||
//! Also: uncompressed 16-bit ARW, and lossless-JPEG tiled ARW through the generic TIFF path.
|
||||
//! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose
|
||||
//! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go
|
||||
//! through the generic TIFF path.
|
||||
|
||||
use crate::tiffraw::{Packing, read_image_in};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use crate::tiffraw::{Packing, check_image, read_image_in};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes};
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{Ifd, Tiff};
|
||||
use rayon::prelude::*;
|
||||
@@ -87,6 +89,79 @@ pub(crate) fn decode_row(row: &[u8], out: &mut [u16]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `info` holds Sony's lossless-compressed layout: LJ92 tiles whose frames are half the tile in each
|
||||
/// direction with four components (checked on the first tile).
|
||||
fn is_quad_tiled(bytes: &[u8], info: &ImageInfo) -> bool {
|
||||
let Layout::Tiles { tile_width, tile_height } = info.layout else { return false };
|
||||
info.samples_per_pixel == 1
|
||||
&& info
|
||||
.chunks(bytes.len() as u64)
|
||||
.first()
|
||||
.and_then(|c| chunk_bytes(bytes, c))
|
||||
.and_then(|src| ljpeg::frame_info(src).ok())
|
||||
.is_some_and(|(fw, fh, nc, _)| nc == 4 && fw * 2 == tile_width as usize && fh * 2 == tile_height as usize)
|
||||
}
|
||||
|
||||
/// Decode Sony's lossless-compressed raw data. Each tile is one LJ92 frame of half the tile's width and height whose
|
||||
/// four components are the tile's 2×2 CFA cells in raster order (top-left, top-right, bottom-left, bottom-right), so
|
||||
/// all four colour planes are predicted separately. Observed in the files' own frame headers (512×512 tiles holding
|
||||
/// 256×256×4 frames) and checked on decoded images; the generic TIFF path reads a frame as a row-major sample stream
|
||||
/// (DNG's convention), which would interleave each tile's left and right halves row by row.
|
||||
fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result<Vec<u16>> {
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let total = check_image(bytes, info)?;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let decoded: Vec<Result<(Chunk, ljpeg::Frame)>> = chunks
|
||||
.par_iter()
|
||||
.map(|c| {
|
||||
let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("tile offset past end of file".into()))?;
|
||||
let f = ljpeg::decode(src, (c.width as usize * c.height as usize).max(1 << 16))?;
|
||||
if f.components != 4 || f.data.len() < f.width * f.height * 4 {
|
||||
return Err(RawError::Corrupt(format!(
|
||||
"lossless ARW tile: {} samples in a {}×{}×{} frame",
|
||||
f.data.len(),
|
||||
f.width,
|
||||
f.height,
|
||||
f.components
|
||||
)));
|
||||
}
|
||||
Ok((*c, f))
|
||||
})
|
||||
.collect();
|
||||
let mut out = vec![0u16; total];
|
||||
let mut ok = 0usize;
|
||||
let mut first_err = None;
|
||||
for r in decoded {
|
||||
let (c, f) = match r {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
first_err.get_or_insert(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ok += 1;
|
||||
let (x0, y0) = (c.x as usize, c.y as usize);
|
||||
for fy in 0..f.height {
|
||||
for k in 0..4 {
|
||||
let y = y0 + 2 * fy + (k >> 1);
|
||||
if y >= h {
|
||||
continue;
|
||||
}
|
||||
for fx in 0..f.width {
|
||||
let x = x0 + 2 * fx + (k & 1);
|
||||
if x < w {
|
||||
out[y * w + x] = f.data[(fy * f.width + fx) * 4 + k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok == 0 {
|
||||
return Err(first_err.unwrap_or_else(|| RawError::Corrupt("no decodable tiles".into())));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
@@ -125,6 +200,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
(RawData::U16(data), 14)
|
||||
}
|
||||
32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant".into())),
|
||||
7 if is_quad_tiled(bytes, &info) => match mode {
|
||||
Mode::Full => (RawData::U16(read_quad_tiles(bytes, &info)?), bits),
|
||||
Mode::Header => {
|
||||
check_image(bytes, &info)?;
|
||||
(RawData::U16(Vec::new()), bits)
|
||||
}
|
||||
},
|
||||
1 => {
|
||||
let packing = if strip_len >= (w * h * 2) as u64 { Packing::Word16 } else { Packing::Msb };
|
||||
(read_image_in(mode, bytes, &info, tiff.order, packing)?, bits)
|
||||
@@ -245,4 +327,48 @@ mod tests {
|
||||
let _ = code_curve(&[]);
|
||||
let _ = code_curve(&[60000, 1, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quad_tiles_place_cfa_cells() {
|
||||
// 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells,
|
||||
// stored column by column in the file as Sony does while TileOffsets stay in raster order
|
||||
let (w, h, tw) = (14usize, 12usize, 8usize);
|
||||
let mosaic: Vec<u16> = (0..w * h).map(|i| 100 + (i * 37 % 4000) as u16).collect();
|
||||
let at = |x: usize, y: usize| if x < w && y < h { mosaic[y * w + x] } else { 0 };
|
||||
let mut file = vec![0u8; 16];
|
||||
let mut offsets = vec![0u64; 4];
|
||||
let mut counts = vec![0u64; 4];
|
||||
for tx in 0..2 {
|
||||
for ty in 0..2 {
|
||||
let mut frame = Vec::new();
|
||||
for fy in 0..tw / 2 {
|
||||
for fx in 0..tw / 2 {
|
||||
let (x, y) = (tx * tw + 2 * fx, ty * tw + 2 * fy);
|
||||
frame.extend([at(x, y), at(x + 1, y), at(x, y + 1), at(x + 1, y + 1)]);
|
||||
}
|
||||
}
|
||||
let enc = ljpeg::encode(&frame, tw / 2, tw / 2, 4, 14, 1, 0);
|
||||
offsets[ty * 2 + tx] = file.len() as u64;
|
||||
counts[ty * 2 + tx] = enc.len() as u64;
|
||||
file.extend(enc);
|
||||
}
|
||||
}
|
||||
let info = ImageInfo {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
bits_per_sample: vec![14],
|
||||
samples_per_pixel: 1,
|
||||
compression: 7,
|
||||
photometric: photometric::CFA,
|
||||
planar: 1,
|
||||
predictor: 1,
|
||||
sample_format: 1,
|
||||
new_subfile_type: 0,
|
||||
layout: Layout::Tiles { tile_width: tw as u32, tile_height: tw as u32 },
|
||||
offsets,
|
||||
byte_counts: counts,
|
||||
};
|
||||
assert!(is_quad_tiled(&file, &info));
|
||||
assert_eq!(read_quad_tiles(&file, &info).unwrap(), mosaic);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ fn corpus_root() -> PathBuf {
|
||||
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
|
||||
const KNOWN_UNSUPPORTED: &[&str] = &[
|
||||
"cr3-", // CR3 / CRX (M11.1)
|
||||
"arw-sony-a7m4-lossless-m", // Sony lossless compressed M/S: subsampled (YCbCr) lossless JPEG
|
||||
"arw-sony-a7m4-lossless-s", // "
|
||||
"raf-fuji-xt20-compressed", // Fujifilm compressed RAF
|
||||
"rw2-panasonic-gh5.", // Panasonic raw format 4 (quantised)
|
||||
"rw2-panasonic-gx80", // "
|
||||
@@ -24,7 +26,6 @@ const KNOWN_UNSUPPORTED: &[&str] = &[
|
||||
"nef-nikon-d5100-lossless", // Nikon Huffman NEF
|
||||
"nef-nikon-d7000-lossy", // "
|
||||
"sraw", // Canon sRAW / mRAW
|
||||
"-lossy.dng", // lossy (DCT) DNG
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -453,7 +453,8 @@ fn rejects_unsupported_and_broken() {
|
||||
let mut raw = base_ifd(w, h, 8, 1, true);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 10]] });
|
||||
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Unsupported(_))));
|
||||
// lossy JPEG DNGs decode now; a strip that isn't a JPEG is reported as corrupt
|
||||
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Corrupt(_))));
|
||||
let mut raw = base_ifd(w, h, 16, 1, true);
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 3, 1]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"pixelsPerPoint": ctx.pixels_per_point(),
|
||||
"canvasRect": app.canvas_rect.map(rect_json),
|
||||
"imageRect": app.image_rect.map(rect_json),
|
||||
"scroll": {"grid": app.grid_scroll, "filmstrip": app.film_scroll},
|
||||
"active": app.session.active().map(|p| p.0),
|
||||
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"activeMask": app.session.active_mask,
|
||||
@@ -81,6 +82,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
||||
"status": app.ui.status,
|
||||
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
|
||||
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
|
||||
"memory": memory(app),
|
||||
})
|
||||
|
||||
@@ -553,6 +553,41 @@ mod tests {
|
||||
|
||||
/// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows
|
||||
/// progress, and reports the result when the files are written.
|
||||
#[test]
|
||||
fn export_dialog_keeps_its_width() {
|
||||
// A dialog row sized as "available width − a guessed button width" made the auto-sized
|
||||
// window grow a little every frame when the real button was wider (issue #8, Linux).
|
||||
let mut h = demo([1400.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.app.services.pick_folder = Some(Box::new(|| None));
|
||||
h.app.services.pick_files = Some(Box::new(Vec::new));
|
||||
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
|
||||
// the widest variant: watermark graphic and every optional row
|
||||
if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog {
|
||||
let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() };
|
||||
wm.text.clear();
|
||||
opts.watermark = Some(wm);
|
||||
}
|
||||
let width = |h: &mut Headless| {
|
||||
let r = h.request("ui.widgets", json!({}), t);
|
||||
r["result"]
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().find(|w| w["id"] == "dialog:window"))
|
||||
.map(|w| w["rect"][2].as_f64().unwrap())
|
||||
.expect("dialog on screen")
|
||||
};
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
}
|
||||
let w0 = width(&mut h);
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
let w1 = width(&mut h);
|
||||
assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}");
|
||||
assert!(w1 < 700.0, "{w1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dialog_runs_in_the_background() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
@@ -621,6 +656,53 @@ mod tests {
|
||||
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended");
|
||||
}
|
||||
|
||||
/// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge
|
||||
/// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos
|
||||
/// show none of it.
|
||||
#[test]
|
||||
fn preview_only_raw_shows_badge_and_notices() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let vis = h.app.session.visible_cloned();
|
||||
let (po, other) = (vis[0], vis[1]);
|
||||
let ph = h.app.session.catalog.photo(po).unwrap().clone();
|
||||
h.app
|
||||
.session
|
||||
.catalog
|
||||
.apply(lightcraft_catalog::Op::SetContent {
|
||||
id: po,
|
||||
width: ph.width,
|
||||
height: ph.height,
|
||||
file_size: ph.file_size,
|
||||
content_hash: ph.content_hash.clone(),
|
||||
preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()),
|
||||
})
|
||||
.unwrap();
|
||||
h.settle(SETTLE);
|
||||
let ids = |h: &mut Headless, filter: &str| -> Vec<String> {
|
||||
let w = h.request("ui.widgets", json!({"filter": filter}), t);
|
||||
w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect()
|
||||
};
|
||||
let badges = ids(&mut h, "badge:previewOnly:");
|
||||
assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge");
|
||||
// the loupe and the Edit panel
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t);
|
||||
h.request("ui.set", json!({"view": "detail"}), t);
|
||||
h.app.ui.right = crate::state::RightPanel::Edit;
|
||||
h.settle(SETTLE);
|
||||
let n = ids(&mut h, "notice:previewOnly:");
|
||||
assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}");
|
||||
// Info
|
||||
h.app.ui.right = crate::state::RightPanel::Info;
|
||||
h.settle(SETTLE);
|
||||
assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string()));
|
||||
// a regular photo: no notice
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t);
|
||||
h.app.ui.right = crate::state::RightPanel::Edit;
|
||||
h.settle(SETTLE);
|
||||
assert!(ids(&mut h, "notice:previewOnly:").is_empty());
|
||||
}
|
||||
|
||||
/// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect.
|
||||
#[test]
|
||||
fn crop_keys() {
|
||||
@@ -728,6 +810,40 @@ mod tests {
|
||||
assert_eq!(exposure(&h), 1.0);
|
||||
}
|
||||
|
||||
/// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo.
|
||||
#[test]
|
||||
fn slider_nudge_and_pick_advance() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
|
||||
h.settle(SETTLE);
|
||||
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
|
||||
assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.key", json!({"key": "Up"}), t);
|
||||
h.request("ui.key", json!({"key": "Up", "shift": true}), t);
|
||||
assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h));
|
||||
h.request("ui.key", json!({"key": "Down"}), t);
|
||||
assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h));
|
||||
let first = h.app.session.active().unwrap();
|
||||
h.request("ui.key", json!({"key": "Z", "shift": true}), t);
|
||||
assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick);
|
||||
assert_ne!(h.app.session.active(), Some(first), "advanced");
|
||||
}
|
||||
|
||||
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
|
||||
#[test]
|
||||
fn return_commits_the_crop_tool() {
|
||||
let mut h = demo([1200.0, 800.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail"}), t);
|
||||
h.request("engine.execute", json!({"command": "panel.crop"}), t);
|
||||
assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools");
|
||||
}
|
||||
|
||||
/// ⌘Q (File → Quit LightCraft) closes the window.
|
||||
#[test]
|
||||
fn cmd_q_quits() {
|
||||
@@ -765,11 +881,76 @@ mod tests {
|
||||
h.app.ui.dialog = None;
|
||||
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
assert!(opts.copy);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The folder scan runs in the background: the request returns at once and the review opens
|
||||
/// when the scan finishes (a folder on a network share must not freeze the window).
|
||||
#[test]
|
||||
fn add_folder_scans_in_the_background() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
|
||||
let png = lightcraft_engine::export::encode_image(
|
||||
&img,
|
||||
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("a.png"), &png).unwrap();
|
||||
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
|
||||
assert_eq!(r["result"]["scanning"], true, "{r}");
|
||||
assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some());
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.scan.is_none());
|
||||
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is
|
||||
/// refused and leaves the running scan's options alone.
|
||||
#[test]
|
||||
fn folder_scan_cancel_and_busy() {
|
||||
let mut h = demo([1200.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
|
||||
let png = lightcraft_engine::export::encode_image(
|
||||
&img,
|
||||
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("a.png"), &png).unwrap();
|
||||
// a scan that can't finish until the test lets it: the probe waits on a flag
|
||||
let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let g = gate.clone();
|
||||
h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
|
||||
while !g.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
|
||||
}));
|
||||
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
|
||||
assert_eq!(r["result"]["scanning"], true, "{r}");
|
||||
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t);
|
||||
assert_ne!(r["ok"], true, "a second scan is refused: {r}");
|
||||
assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options");
|
||||
let r = h.request("ui.inspect", json!({}), t);
|
||||
assert!(r["result"]["scan"].is_object(), "{r}");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:scanCancel"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.scan.is_none(), "Cancel closes the scan at once");
|
||||
gate.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group
|
||||
/// leaves it alone. ⌘F puts typing into the search field.
|
||||
#[test]
|
||||
@@ -841,7 +1022,7 @@ mod tests {
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green));
|
||||
h.request("engine.execute", json!({"command": "dialog.labelNames"}), t);
|
||||
if let Some(crate::state::Dialog::LabelNames { names }) = &mut h.app.ui.dialog {
|
||||
if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog {
|
||||
names[2] = "Approved".into();
|
||||
} else {
|
||||
panic!("no dialog");
|
||||
@@ -880,9 +1061,11 @@ mod tests {
|
||||
let n0 = h.app.session.catalog.len();
|
||||
let undo0 = h.app.session.undo.len();
|
||||
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t);
|
||||
assert_eq!(r["result"]["candidates"], 6, "{r}");
|
||||
assert_eq!(r["result"]["duplicates"], 1, "{r}");
|
||||
assert_eq!(r["result"]["scanning"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
assert_eq!(opts.candidates.len(), 6);
|
||||
assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1);
|
||||
assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails");
|
||||
// uncheck the first photo; name a new album and keywords
|
||||
let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t);
|
||||
@@ -909,6 +1092,53 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across
|
||||
/// the import's batches.
|
||||
#[test]
|
||||
fn import_copy_renames_into_destination() {
|
||||
let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (src, dest) = (base.join("card"), base.join("out"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
for i in 0..10u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
|
||||
}
|
||||
let dest_s = dest.to_string_lossy().to_string();
|
||||
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
|
||||
app.ui.view = crate::state::ViewMode::PhotoGrid;
|
||||
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
let t = Duration::from_secs(10);
|
||||
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
|
||||
assert_eq!(r["result"]["scanning"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
assert_eq!(opts.candidates.len(), 10);
|
||||
for id in ["button:importCopy", "button:importDest"] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), t);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
}
|
||||
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
|
||||
assert!(opts.copy);
|
||||
assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder");
|
||||
opts.organize = "flat".into();
|
||||
opts.rename = "Trip-{seq:2}".into();
|
||||
} else {
|
||||
panic!("no import dialog");
|
||||
}
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.import.is_none(), "finished");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
|
||||
/// the engine; the delete confirmation guards ⌫.
|
||||
#[test]
|
||||
@@ -1010,6 +1240,48 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
|
||||
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
|
||||
#[test]
|
||||
fn click_zoom_ratio_animates() {
|
||||
use crate::render::Slot;
|
||||
use crate::state::Zoom;
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default");
|
||||
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t);
|
||||
assert_eq!(r["result"]["ratio"], 3, "{r}");
|
||||
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t);
|
||||
assert_ne!(r["ok"], true, "{r}");
|
||||
let fit = h.app.image_rect.unwrap();
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
|
||||
assert!(h.app.ui.zoom_anim, "animation started");
|
||||
// every animation frame asks for the same (final-size) render
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
keys.extend(h.app.renderer.wanted(Slot::Main));
|
||||
if !h.app.ui.zoom_anim {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}");
|
||||
h.settle(SETTLE);
|
||||
assert!(!h.app.ui.zoom_anim, "animation finished");
|
||||
assert!(h.app.image_rect.unwrap().width() > fit.width());
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
// Z zooms to the same ratio as a click
|
||||
h.request("ui.key", json!({"key": "z"}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
|
||||
h.request("ui.key", json!({"key": "z"}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// The Navigator appears when zoomed in; clicking it pans to that point.
|
||||
#[test]
|
||||
fn navigator_pans_the_zoomed_loupe() {
|
||||
|
||||
+188
-14
@@ -5,9 +5,10 @@
|
||||
//! frame, with a progress window; the whole import is one undo step.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_engine::import::ImportCandidate;
|
||||
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::render::Slot;
|
||||
@@ -15,7 +16,7 @@ use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Files per batch (one batch per frame, so the progress window updates).
|
||||
const BATCH: usize = 8;
|
||||
pub(crate) const BATCH: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -32,6 +33,16 @@ pub struct ImportDialog {
|
||||
pub preset: String,
|
||||
/// Comma-separated keywords.
|
||||
pub keywords: String,
|
||||
/// Copy: destination folder ("" = the library's Originals/).
|
||||
pub destination: String,
|
||||
/// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`.
|
||||
pub organize: String,
|
||||
/// Copy: file-name template for the copies ("" = keep the names).
|
||||
pub rename: String,
|
||||
/// Metadata preset name ("" = none).
|
||||
pub metadata_preset: String,
|
||||
/// Copy: raws are copied as DNG.
|
||||
pub dng: bool,
|
||||
}
|
||||
|
||||
impl ImportDialog {
|
||||
@@ -69,19 +80,93 @@ pub struct ImportTask {
|
||||
first: Option<u64>,
|
||||
}
|
||||
|
||||
/// Scan `paths` and open the review dialog. Returns the candidate counts.
|
||||
/// A folder scan running on a worker thread (a network share can take minutes to read; the
|
||||
/// window must keep answering meanwhile).
|
||||
pub struct ScanTask {
|
||||
progress: std::sync::Arc<ScanProgress>,
|
||||
rx: std::sync::mpsc::Receiver<ScanOutput>,
|
||||
/// Open the review with "copy into the library" checked (a camera / card).
|
||||
pub copy: bool,
|
||||
}
|
||||
|
||||
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
|
||||
pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
|
||||
let r = app.session.execute("library.importPreview", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
let candidates: Vec<ImportCandidate> = serde_json::from_value(r["candidates"].clone()).unwrap_or_default();
|
||||
if candidates.is_empty() {
|
||||
app.toast(&egui::Context::default(), "No photos found");
|
||||
return Ok(json!({"candidates": 0}));
|
||||
if app.scan.is_some() {
|
||||
return Err("a scan is already running".into());
|
||||
}
|
||||
let (input, paths) = ScanInput::new(&mut app.session, &paths);
|
||||
let progress = std::sync::Arc::new(ScanProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = progress.clone();
|
||||
let job = move || {
|
||||
let _ = tx.send(scan_with(input, &paths, &p));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false });
|
||||
Ok(json!({"scanning": true}))
|
||||
}
|
||||
|
||||
/// Collect a finished scan and open the review (called every frame).
|
||||
pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = app.scan.as_ref() else { return };
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let out = match task.rx.try_recv() {
|
||||
Ok(o) => o,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(_) => {
|
||||
app.scan = None;
|
||||
app.toast(ctx, "Scan failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let task = app.scan.take().expect("scan task");
|
||||
if task.progress.cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
app.session.import_probes = out.probes;
|
||||
if out.candidates.is_empty() {
|
||||
app.toast(ctx, "No photos found");
|
||||
return;
|
||||
}
|
||||
app.renderer.forget_imports();
|
||||
let d = ImportDialog::new(candidates);
|
||||
let out = json!({"candidates": d.candidates.len(), "duplicates": r["duplicates"]});
|
||||
let mut d = ImportDialog::new(out.candidates);
|
||||
d.copy = task.copy;
|
||||
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The progress window while a folder is being scanned.
|
||||
pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = &app.scan else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let total = task.progress.total.load(Ordering::Relaxed);
|
||||
let done = task.progress.done.load(Ordering::Relaxed);
|
||||
let text = if total == 0 { "Looking for photos…".to_string() } else { format!("Reading photos… {done} of {total}") };
|
||||
let mut cancel = false;
|
||||
egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0));
|
||||
let r = ui.button("Cancel");
|
||||
register(ui.ctx(), "button:scanCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
},
|
||||
);
|
||||
if cancel {
|
||||
// the worker stops at its next file; don't wait for it (a NAS read can take a while)
|
||||
task.progress.cancel.store(true, Ordering::Relaxed);
|
||||
app.scan = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl ScanTask {
|
||||
/// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed).
|
||||
pub fn status(&self) -> Value {
|
||||
json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)})
|
||||
}
|
||||
}
|
||||
|
||||
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`).
|
||||
@@ -103,6 +188,24 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
|
||||
if !d.preset.is_empty() {
|
||||
params["preset"] = json!(d.preset);
|
||||
}
|
||||
if !d.metadata_preset.is_empty() {
|
||||
params["metadataPreset"] = json!(d.metadata_preset);
|
||||
}
|
||||
if d.copy {
|
||||
if !d.destination.trim().is_empty() {
|
||||
params["destination"] = json!(d.destination.trim());
|
||||
}
|
||||
if !d.organize.is_empty() {
|
||||
params["organize"] = json!(d.organize);
|
||||
}
|
||||
if !d.rename.trim().is_empty() {
|
||||
params["rename"] = json!(d.rename.trim());
|
||||
params["renameStart"] = json!(1);
|
||||
}
|
||||
if d.dng {
|
||||
params["dng"] = json!(true);
|
||||
}
|
||||
}
|
||||
let total = queue.len();
|
||||
app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() });
|
||||
app.renderer.forget_imports();
|
||||
@@ -116,6 +219,10 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let batch: Vec<String> = task.queue.drain(..n).collect();
|
||||
let mut p = task.params.clone();
|
||||
p["paths"] = json!(batch);
|
||||
// renamed copies keep counting across batches
|
||||
if p.get("rename").is_some() {
|
||||
p["renameStart"] = json!(1 + task.imported);
|
||||
}
|
||||
let r = app.session.execute("library.import", &p);
|
||||
let task = app.import.as_mut().expect("import task");
|
||||
task.done += n;
|
||||
@@ -216,12 +323,64 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() {
|
||||
d.copy = false;
|
||||
}
|
||||
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk);
|
||||
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy into library", d.copy)).inner;
|
||||
if r.on_hover_text("Copy into the library's Originals/YYYY/YYYY-MM-DD/ folders").clicked() {
|
||||
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some();
|
||||
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy)).inner;
|
||||
if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() {
|
||||
d.copy = true;
|
||||
}
|
||||
});
|
||||
if d.copy {
|
||||
field(ui, "Copy to", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
|
||||
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
|
||||
if app.services.pick_folder.is_some()
|
||||
&& crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked()
|
||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
|
||||
{
|
||||
d.destination = f;
|
||||
}
|
||||
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() {
|
||||
d.destination.clear();
|
||||
}
|
||||
});
|
||||
field(ui, "Folders", |ui| {
|
||||
let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")];
|
||||
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
|
||||
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
|
||||
egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
|
||||
for (k, label) in opts {
|
||||
if ui.selectable_label(key == k, label).clicked() {
|
||||
d.organize = k.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
field(ui, "Raw files", |ui| {
|
||||
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
|
||||
register(ui.ctx(), "check:importDng", r.rect);
|
||||
});
|
||||
field(ui, "Rename", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), "field:importRename", r.rect);
|
||||
});
|
||||
if !d.rename.trim().is_empty()
|
||||
&& let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c)
|
||||
{
|
||||
let mut q = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(0),
|
||||
lightcraft_catalog::Source::Demo { scene: 0 },
|
||||
&c.name,
|
||||
&c.format,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
);
|
||||
q.captured = c.captured.clone();
|
||||
let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1);
|
||||
ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
}
|
||||
let albums: Vec<(u64, String)> = {
|
||||
let mut v: Vec<(u64, String)> =
|
||||
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
|
||||
@@ -270,6 +429,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
});
|
||||
});
|
||||
if !app.session.metadata_presets.is_empty() {
|
||||
field(ui, "Metadata", |ui| {
|
||||
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
|
||||
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() {
|
||||
d.metadata_preset.clear();
|
||||
}
|
||||
for m in &app.session.metadata_presets {
|
||||
if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() {
|
||||
d.metadata_preset = m.name.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
field(ui, "Keywords", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), "field:importKeywords", r.rect);
|
||||
|
||||
+271
-5
@@ -22,8 +22,12 @@ pub mod state;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_scroll;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
@@ -46,16 +50,22 @@ pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
|
||||
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
/// Open a URL in the user's browser.
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
/// Open a file in an application (`app` = "" for the system's default one).
|
||||
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
|
||||
|
||||
/// Platform services injected by the host app (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Show an open dialog for photos; returns paths.
|
||||
pub pick_files: Option<PickFiles>,
|
||||
/// Open dialog for preset files (`.lcpreset`, `.xmp`).
|
||||
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
|
||||
pub pick_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
/// Open dialog for point-curve preset files (`.lccurve`).
|
||||
pub pick_curve_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lccurve` file.
|
||||
pub save_curve_preset_file: Option<SaveFile>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
|
||||
pub write_shared: Option<SharedWrite>,
|
||||
@@ -67,6 +77,8 @@ pub struct Services {
|
||||
pub pick_folder: Option<PickFolder>,
|
||||
/// Open a web link in the browser (Help menu, About, Discord button).
|
||||
pub open_url: Option<OpenUrlFn>,
|
||||
/// Open a file in an external editor (Edit in External Editor; desktop only).
|
||||
pub open_with: Option<OpenWithFn>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -76,6 +88,9 @@ pub struct Perf {
|
||||
}
|
||||
|
||||
pub struct LightcraftApp {
|
||||
/// Per-catalog-revision caches of library-wide results the panels show every frame
|
||||
/// (expensive on big libraries).
|
||||
pub caches: Caches,
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
@@ -107,6 +122,10 @@ pub struct LightcraftApp {
|
||||
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
pub image_rect: Option<egui::Rect>,
|
||||
/// Scroll offsets (points) of the photo grid (vertical) and the filmstrip (horizontal) as
|
||||
/// drawn last (`ui.inspect` → `scroll`).
|
||||
pub grid_scroll: Option<f32>,
|
||||
pub film_scroll: Option<f32>,
|
||||
/// Widget registry from the last frame (automation ids → rects).
|
||||
pub widgets: Vec<(String, egui::Rect)>,
|
||||
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
|
||||
@@ -118,6 +137,8 @@ pub struct LightcraftApp {
|
||||
pub merge: merge::MergeState,
|
||||
/// An import in progress (the import review dialog's batches).
|
||||
pub import: Option<import::ImportTask>,
|
||||
/// A folder scan in progress (feeds the import review).
|
||||
pub scan: Option<import::ScanTask>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// The files of the last finished background export (`ui.inspect` → `export.last`).
|
||||
@@ -143,6 +164,7 @@ impl LightcraftApp {
|
||||
services,
|
||||
renderer: render::Renderer::default(),
|
||||
perf: Perf::default(),
|
||||
caches: Caches::default(),
|
||||
integrated_titlebar: false,
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
@@ -159,11 +181,14 @@ impl LightcraftApp {
|
||||
last_time: 0.0,
|
||||
canvas_rect: None,
|
||||
image_rect: None,
|
||||
grid_scroll: None,
|
||||
film_scroll: None,
|
||||
widgets: vec![],
|
||||
gesture: None,
|
||||
loupe_shown: None,
|
||||
merge: merge::MergeState::default(),
|
||||
import: None,
|
||||
scan: None,
|
||||
export: None,
|
||||
last_export_result: None,
|
||||
hover_preview: None,
|
||||
@@ -193,6 +218,54 @@ impl LightcraftApp {
|
||||
}
|
||||
|
||||
/// Show a transient toast at the bottom of the canvas (like the reference app's HUD).
|
||||
/// Advance a running slideshow (wrapping around at the end).
|
||||
fn slideshow_tick(&mut self, ctx: &egui::Context) {
|
||||
let Some((interval, due, paused)) = self.ui.slideshow else { return };
|
||||
if !self.ui.fullscreen {
|
||||
self.ui.slideshow = None;
|
||||
return;
|
||||
}
|
||||
if paused {
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now >= due {
|
||||
let vis = self.session.visible_cloned();
|
||||
if let Some(cur) = self.session.active()
|
||||
&& !vis.is_empty()
|
||||
{
|
||||
let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len());
|
||||
let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]}));
|
||||
}
|
||||
self.ui.slideshow = Some((interval, now + interval, false));
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval)));
|
||||
}
|
||||
|
||||
/// Announce the start and end of a Build Previews run.
|
||||
fn preview_build_status(&mut self, ctx: &egui::Context) {
|
||||
use std::sync::atomic::Ordering;
|
||||
let Some(b) = self.session.preview_build.clone() else { return };
|
||||
let key = std::sync::Arc::as_ptr(&b) as usize;
|
||||
if b.finished.load(Ordering::Relaxed) {
|
||||
if self.ui.preview_build_seen != Some((key, true)) {
|
||||
self.ui.preview_build_seen = Some((key, true));
|
||||
let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed));
|
||||
let mut msg = format!("Previews ready for {done} photo{}", if done == 1 { "" } else { "s" });
|
||||
if failed > 0 {
|
||||
msg.push_str(&format!(" · {failed} couldn't be rendered"));
|
||||
}
|
||||
self.toast(ctx, msg);
|
||||
}
|
||||
} else {
|
||||
if self.ui.preview_build_seen != Some((key, false)) {
|
||||
self.ui.preview_build_seen = Some((key, false));
|
||||
self.toast(ctx, format!("Building previews for {} photos…", b.total));
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
|
||||
let t = ctx.input(|i| i.time);
|
||||
self.ui.toast = Some((text.into(), t + 1.4));
|
||||
@@ -348,7 +421,36 @@ impl LightcraftApp {
|
||||
}
|
||||
self.renderer.poll(ctx, &mut self.session);
|
||||
merge::poll(self, ctx);
|
||||
import::poll_scan(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
self.preview_build_status(ctx);
|
||||
self.slideshow_tick(ctx);
|
||||
// back from an external editor: pick up the files it saved
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() {
|
||||
let ids = self.ui.external_edits.clone();
|
||||
if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids}))
|
||||
&& r["reloaded"].as_array().is_some_and(|a| !a.is_empty())
|
||||
{
|
||||
self.toast(ctx, "Updated the edits saved in the external editor");
|
||||
}
|
||||
}
|
||||
self.ui.was_focused = focused;
|
||||
// auto import: scan the watched folder every few seconds
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.session.import_defaults.auto_folder.is_some() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now - self.ui.auto_import_at >= 3.0 {
|
||||
self.ui.auto_import_at = now;
|
||||
if let Ok(r) = self.session.execute("library.autoImportScan", &serde_json::json!({})) {
|
||||
let n = r["imported"].as_array().map_or(0, Vec::len);
|
||||
if n > 0 {
|
||||
self.toast(ctx, format!("Auto Import: added {n} photo{}", if n == 1 { "" } else { "s" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(3));
|
||||
}
|
||||
self.session.persist_if_dirty();
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
@@ -359,8 +461,13 @@ impl LightcraftApp {
|
||||
{
|
||||
let dropped: Vec<String> =
|
||||
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
|
||||
if !dropped.is_empty() {
|
||||
let _ = self.run("library.import", serde_json::json!({"paths": dropped}));
|
||||
// preset files import as presets, everything else as photos
|
||||
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
|
||||
if !presets.is_empty() {
|
||||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
}
|
||||
if !photos.is_empty() {
|
||||
let _ = self.run("library.import", serde_json::json!({"paths": photos}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,6 +545,7 @@ impl LightcraftApp {
|
||||
if self.ui.fullscreen {
|
||||
// full-screen preview: the photo alone on black
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
|
||||
panels::second::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
@@ -459,16 +567,23 @@ impl LightcraftApp {
|
||||
}
|
||||
panels::bottombar::show(self, ui);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
let bg =
|
||||
if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg };
|
||||
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey | state::ViewMode::Reference)
|
||||
{
|
||||
t.canvas
|
||||
} else {
|
||||
t.grid_bg
|
||||
};
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
|
||||
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
|
||||
state::ViewMode::Detail => panels::detail::show(self, ui),
|
||||
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
|
||||
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
|
||||
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
|
||||
});
|
||||
panels::second::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
@@ -514,3 +629,154 @@ pub struct HoverPreview {
|
||||
pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool {
|
||||
d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.")
|
||||
}
|
||||
|
||||
/// Files dropped on the window that are presets rather than photos.
|
||||
pub fn is_preset_file(path: &str) -> bool {
|
||||
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_tests {
|
||||
#[test]
|
||||
fn dropped_presets_are_told_apart_from_photos() {
|
||||
for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] {
|
||||
assert!(super::is_preset_file(p), "{p}");
|
||||
}
|
||||
for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] {
|
||||
assert!(!super::is_preset_file(p), "{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Results recomputed only when the catalog (or their inputs) change.
|
||||
#[derive(Default)]
|
||||
pub struct Caches {
|
||||
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
|
||||
date_runs: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateRun>>)>,
|
||||
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
|
||||
counts: Option<(u64, LibraryCounts)>,
|
||||
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
|
||||
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
|
||||
/// The grid's layout (by photos, shapes, width, thumbnail size, grouping).
|
||||
pub grid_layout: Option<(u64, std::sync::Arc<panels::grid::GridLayout>)>,
|
||||
}
|
||||
|
||||
/// The left panel's counts.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct LibraryCounts {
|
||||
pub total: usize,
|
||||
pub picks: usize,
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
/// The values the filter bar's pickers offer.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FilterValues {
|
||||
pub cameras: Vec<String>,
|
||||
pub lenses: Vec<String>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
|
||||
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
|
||||
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
|
||||
}
|
||||
|
||||
impl Caches {
|
||||
/// The library's keyword tree.
|
||||
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
|
||||
match &self.keyword_tree {
|
||||
Some((r, t)) if *r == cat.revision => t.clone(),
|
||||
_ => {
|
||||
let t = std::sync::Arc::new(cat.keyword_tree());
|
||||
self.keyword_tree = Some((cat.revision, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
}
|
||||
/// All Photos / Picks / Recently Deleted counts.
|
||||
pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts {
|
||||
if let Some((r, c)) = self.counts
|
||||
&& r == cat.revision
|
||||
{
|
||||
return c;
|
||||
}
|
||||
let mut c = LibraryCounts::default();
|
||||
for p in cat.photos() {
|
||||
if p.in_library() {
|
||||
c.total += 1;
|
||||
if p.flag == lightcraft_catalog::Flag::Pick {
|
||||
c.picks += 1;
|
||||
}
|
||||
} else if p.deleted && !p.local {
|
||||
c.deleted += 1;
|
||||
}
|
||||
}
|
||||
self.counts = Some((cat.revision, c));
|
||||
c
|
||||
}
|
||||
/// The By Date tree.
|
||||
pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> {
|
||||
match &self.date_groups {
|
||||
Some((r, g)) if *r == cat.revision => g.clone(),
|
||||
_ => {
|
||||
let g = std::sync::Arc::new(cat.date_groups());
|
||||
self.date_groups = Some((cat.revision, g.clone()));
|
||||
g
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Cameras, lenses and keywords in the library (filter bar pickers).
|
||||
pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> {
|
||||
match &self.filter_values {
|
||||
Some((r, v)) if *r == cat.revision => v.clone(),
|
||||
_ => {
|
||||
let distinct = |mut v: Vec<String>| {
|
||||
v.retain(|s| !s.trim().is_empty());
|
||||
v.sort_by_key(|s| s.to_lowercase());
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let lib = || cat.photos().filter(|p| p.in_library());
|
||||
let v = std::sync::Arc::new(FilterValues {
|
||||
cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()),
|
||||
lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()),
|
||||
keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(),
|
||||
});
|
||||
self.filter_values = Some((cat.revision, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Date headers for `ids` in the grid.
|
||||
pub fn date_runs(
|
||||
&mut self,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
ids: &[lightcraft_catalog::PhotoId],
|
||||
key: lightcraft_catalog::SortKey,
|
||||
by: lightcraft_catalog::GroupBy,
|
||||
) -> std::sync::Arc<Vec<lightcraft_catalog::DateRun>> {
|
||||
let k = key_of((cat.revision, ids, format!("{key:?}{by:?}")));
|
||||
match &self.date_runs {
|
||||
Some((h, r)) if *h == k => r.clone(),
|
||||
_ => {
|
||||
let r = std::sync::Arc::new(cat.date_runs(ids, key, by));
|
||||
self.date_runs = Some((k, r.clone()));
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Keyword suggestions for a photo with `current` keywords and the typed `prefix`.
|
||||
pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> {
|
||||
let k = key_of((cat.revision, current, prefix, n));
|
||||
match &self.suggestions {
|
||||
Some((h, v)) if *h == k => v.clone(),
|
||||
_ => {
|
||||
let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n));
|
||||
self.suggestions = Some((k, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,12 @@ pub const LINKS: &[(&str, &str, &str)] = &[
|
||||
("app.website", "LightCraft Website", APP_PAGE),
|
||||
("app.github", "LightCraft on GitHub", GITHUB),
|
||||
("app.artcraft", "ArtCraft Website", WEBSITE),
|
||||
("app.feedback", "Send Feedback…", FEEDBACK),
|
||||
];
|
||||
|
||||
/// Where feedback and bug reports go.
|
||||
pub const FEEDBACK: &str = "https://github.com/storytold/lightcraft/issues/new";
|
||||
|
||||
/// The URL behind a link command id.
|
||||
pub fn url_of(cmd: &str) -> Option<&'static str> {
|
||||
LINKS.iter().find(|(id, _, _)| *id == cmd).map(|(_, _, u)| *u)
|
||||
|
||||
@@ -60,6 +60,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"---",
|
||||
"dialog.newAlbum",
|
||||
"dialog.newFolder",
|
||||
"dialog.smartAlbum",
|
||||
"dialog.newSmartAlbum",
|
||||
"---",
|
||||
"file.importPresets",
|
||||
@@ -70,6 +71,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"@Export with Preset",
|
||||
"---",
|
||||
"library.toggleAutoWriteXmp",
|
||||
"@Previews",
|
||||
"---",
|
||||
"app.quit",
|
||||
],
|
||||
@@ -84,6 +86,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"dialog.copySettings",
|
||||
"develop.paste",
|
||||
"dialog.pasteSettings",
|
||||
"develop.sync",
|
||||
"develop.autoSync",
|
||||
"---",
|
||||
"library.selectAll",
|
||||
"library.selectNone",
|
||||
@@ -99,11 +103,13 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
&[
|
||||
"view.photoGrid",
|
||||
"view.squareGrid",
|
||||
"@Grid Info",
|
||||
"view.detail",
|
||||
"view.compare",
|
||||
"view.survey",
|
||||
"---",
|
||||
"view.leftPanel",
|
||||
"view.photoCounts",
|
||||
"view.filmstrip",
|
||||
"view.histogram",
|
||||
"view.navigator",
|
||||
@@ -124,6 +130,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"view.zoom100",
|
||||
"---",
|
||||
"view.clipping",
|
||||
"view.softProof",
|
||||
"view.maskOverlay",
|
||||
"view.maskOverlayMode",
|
||||
"view.maskOverlayColor",
|
||||
@@ -147,6 +154,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"@Set Rating",
|
||||
"@Set Flag",
|
||||
"@Set Color Label",
|
||||
"@Metadata Preset",
|
||||
"view.autoAdvance",
|
||||
"---",
|
||||
"photo.rotateLeft",
|
||||
@@ -192,7 +200,23 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"@Tools",
|
||||
],
|
||||
),
|
||||
("Help", &["app.discord", "---", "app.website", "app.github", "app.artcraft", "---", "app.shortcuts", "---", "app.about"]),
|
||||
(
|
||||
"Help",
|
||||
&[
|
||||
"app.discord",
|
||||
"app.feedback",
|
||||
"---",
|
||||
"app.website",
|
||||
"app.github",
|
||||
"app.artcraft",
|
||||
"---",
|
||||
"app.whatsNew",
|
||||
"app.shortcuts",
|
||||
"app.systemInfo",
|
||||
"---",
|
||||
"app.about",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Registry entries that are reached another way (parameterized commands are expanded into
|
||||
@@ -220,15 +244,20 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
|
||||
let u = &app.ui;
|
||||
let panel = |p: RightPanel| Some(u.right == p);
|
||||
match id {
|
||||
"develop.autoSync" => Some(app.session.auto_sync),
|
||||
"view.photoCounts" => Some(u.show_counts),
|
||||
"view.secondWindow" => Some(u.second_window),
|
||||
"view.photoGrid" => Some(u.view == ViewMode::PhotoGrid),
|
||||
"view.squareGrid" => Some(u.view == ViewMode::SquareGrid),
|
||||
"view.detail" => Some(u.view == ViewMode::Detail),
|
||||
"view.compare" => Some(u.view == ViewMode::Compare),
|
||||
"view.survey" => Some(u.view == ViewMode::Survey),
|
||||
"view.reference" => Some(u.view == ViewMode::Reference),
|
||||
"view.leftPanel" => Some(u.left_panel),
|
||||
"view.filmstrip" => Some(u.filmstrip),
|
||||
"view.histogram" => Some(u.histogram),
|
||||
"view.clipping" => Some(u.show_clipping),
|
||||
"view.softProof" => Some(u.soft_proof),
|
||||
"view.maskOverlay" => Some(u.mask_overlay),
|
||||
"view.maskPins" => Some(u.mask_pins),
|
||||
"view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original),
|
||||
@@ -273,6 +302,41 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
|
||||
let has = active.is_some();
|
||||
Some(match name {
|
||||
"Previews" => {
|
||||
let running = app.session.preview_build.as_ref().is_some_and(|b| !b.finished.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let scope = if app.session.selection.ids.len() > 1 { "Selected" } else { "Visible" };
|
||||
vec![
|
||||
item(
|
||||
"library.buildPreviews",
|
||||
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
|
||||
format!("Build Standard-Sized Previews ({scope})"),
|
||||
None,
|
||||
!running,
|
||||
None,
|
||||
),
|
||||
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
|
||||
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
|
||||
MenuNode::Separator,
|
||||
item(
|
||||
"library.smartPreviews",
|
||||
Value::Null,
|
||||
format!("Build Smart Previews ({scope})"),
|
||||
None,
|
||||
app.session.media.smart_dir.is_some(),
|
||||
None,
|
||||
),
|
||||
item(
|
||||
"library.smartPreviews",
|
||||
json!({"discard": true}),
|
||||
format!("Discard Smart Previews ({scope})"),
|
||||
None,
|
||||
app.session.media.smart_dir.is_some(),
|
||||
None,
|
||||
),
|
||||
MenuNode::Separator,
|
||||
item("library.clearPreviews", Value::Null, "Discard Preview Cache", None, true, None),
|
||||
]
|
||||
}
|
||||
"Set Rating" => (0..=5u8)
|
||||
.map(|r| {
|
||||
let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) };
|
||||
@@ -313,9 +377,28 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
v.push(MenuNode::Separator);
|
||||
v.push(item("photo.label", json!({"label": "none"}), "None", None, has, Some(active.as_ref().is_some_and(|p| p.label.is_none()))));
|
||||
v.push(MenuNode::Separator);
|
||||
// label sets: each a checkable item; Edit… names them
|
||||
let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session);
|
||||
let current = sets["current"].as_str().map(str::to_string);
|
||||
for set in sets["sets"].as_array().into_iter().flatten() {
|
||||
let name = set["name"].as_str().unwrap_or_default();
|
||||
v.push(item(
|
||||
"label.applySet",
|
||||
json!({"name": name}),
|
||||
format!("Label Set: {name}"),
|
||||
None,
|
||||
true,
|
||||
Some(current.as_deref() == Some(name)),
|
||||
));
|
||||
}
|
||||
v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None));
|
||||
v
|
||||
}
|
||||
"Grid Info" => ["filename", "exposure", "date"]
|
||||
.into_iter()
|
||||
.zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"])
|
||||
.map(|(k, label)| item("view.gridInfo", json!({"info": k}), label, None, true, Some(app.ui.grid_info == k)))
|
||||
.collect(),
|
||||
"Sort" => {
|
||||
use lightcraft_catalog::SortKey::*;
|
||||
let cur = app.session.sort;
|
||||
@@ -353,6 +436,20 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
devices.into_iter().map(|d| item("file.addFromDevice", json!({"path": d.path}), format!("{}…", d.name), None, true, None)).collect()
|
||||
}
|
||||
}
|
||||
"Metadata Preset" => {
|
||||
let sel = !app.session.selection.ids.is_empty() || has;
|
||||
let mut v: Vec<MenuNode> = app
|
||||
.session
|
||||
.metadata_presets
|
||||
.iter()
|
||||
.map(|m| item("metadata.applyPreset", json!({"name": m.name}), m.name.clone(), None, sel, None))
|
||||
.collect();
|
||||
if !v.is_empty() {
|
||||
v.push(MenuNode::Separator);
|
||||
}
|
||||
v.push(item("dialog.saveMetadataPreset", Value::Null, "Save Metadata Preset…", None, has, None));
|
||||
v
|
||||
}
|
||||
"Select by" => {
|
||||
let mut v: Vec<MenuNode> = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")]
|
||||
.into_iter()
|
||||
@@ -771,6 +868,26 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_preset_menu() {
|
||||
let mut app = app();
|
||||
app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap();
|
||||
run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap();
|
||||
if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog {
|
||||
*value = "Mine".into();
|
||||
}
|
||||
let d = app.ui.dialog.take().unwrap();
|
||||
crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap();
|
||||
let bar = menu_bar(&app);
|
||||
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
|
||||
let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") };
|
||||
assert_eq!(params["name"], "Mine");
|
||||
let other = app.session.visible()[1];
|
||||
app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap();
|
||||
run_item(&mut app, id, params.clone()).unwrap();
|
||||
assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_text_per_platform() {
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
|
||||
|
||||
+373
-18
@@ -11,11 +11,16 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
|
||||
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
|
||||
|
||||
pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("view.photoGrid", "Photo Grid", Some("G"), "View"),
|
||||
("view.squareGrid", "Square Grid", Some("Shift+G"), "View"),
|
||||
("view.photoGrid", "Photo Grid", None, "View"),
|
||||
("view.squareGrid", "Square Grid", None, "View"),
|
||||
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
|
||||
("view.gridToggle", "Grid", Some("G"), ""),
|
||||
("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"),
|
||||
("view.detail", "Detail", Some("D"), "View"),
|
||||
("view.compare", "Compare", Some("Shift+C"), "View"),
|
||||
("view.survey", "Survey", Some("N"), "View"),
|
||||
("view.reference", "Reference View", Some("Shift+R"), "View"),
|
||||
("photo.setReference", "Set as Reference Photo", None, ""),
|
||||
("compare.swap", "Swap Compare Photos", None, "View"),
|
||||
("compare.makeSelect", "Make Candidate the Select", None, "View"),
|
||||
("view.autoAdvance", "Auto Advance", None, "Photo"),
|
||||
@@ -27,11 +32,15 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"),
|
||||
("view.showOriginal", "Show Original", Some("\\"), "View"),
|
||||
("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"),
|
||||
("view.zoom100", "Zoom 100%", Some("Cmd+1"), "View"),
|
||||
("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"),
|
||||
("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"),
|
||||
// the ratio a click (and Z / Space) zooms to
|
||||
("view.clickZoom", "Click Zoom Ratio", None, ""),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
|
||||
("view.clipping", "Show Clipping", Some("J"), "View"),
|
||||
// in grids S expands/collapses stacks (the engine command it shadows)
|
||||
("view.softProof", "Soft Proofing", Some("S"), "View"),
|
||||
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
|
||||
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
|
||||
// Shift+O in the Masking panel (elsewhere it cycles the crop overlay)
|
||||
@@ -42,6 +51,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
|
||||
("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"),
|
||||
("view.back", "Back to Grid", Some("Escape"), ""),
|
||||
("tool.done", "Done", Some("Enter"), ""),
|
||||
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
|
||||
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
|
||||
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
|
||||
@@ -59,11 +69,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("panel.versions", "Versions", Some("Shift+V"), "Window"),
|
||||
("panel.activity", "History", None, "Window"),
|
||||
("panel.close", "Close Panel", None, ""),
|
||||
("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"),
|
||||
("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"),
|
||||
("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"),
|
||||
("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"),
|
||||
("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"),
|
||||
("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"),
|
||||
("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"),
|
||||
("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"),
|
||||
("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"),
|
||||
("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"),
|
||||
("tool.brush", "Brush", Some("B"), "Window>Tools"),
|
||||
("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"),
|
||||
("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"),
|
||||
@@ -76,13 +86,21 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"),
|
||||
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
|
||||
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
|
||||
("dialog.smartAlbum", "New Smart Album…", None, "File"),
|
||||
("view.photoCounts", "Show Photo Counts", None, "View"),
|
||||
("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"),
|
||||
("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"),
|
||||
("tool.keywordPainter", "Keyword Painter", None, ""),
|
||||
("view.gridInfo", "Grid Info", None, ""),
|
||||
("dialog.allMetadata", "All Metadata…", None, "Photo"),
|
||||
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
|
||||
("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"),
|
||||
("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"),
|
||||
("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"),
|
||||
("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"),
|
||||
("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"),
|
||||
("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"),
|
||||
("dialog.export", "Export…", None, "File"),
|
||||
("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"),
|
||||
("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"),
|
||||
("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"),
|
||||
("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"),
|
||||
@@ -94,21 +112,29 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("file.addFromDevice", "Add from Device", None, ""),
|
||||
("file.findMissing", "Find Missing Photos…", None, "File"),
|
||||
("photo.locate", "Locate Missing File…", None, ""),
|
||||
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),
|
||||
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
|
||||
("file.importPresets", "Import Presets…", None, "File"),
|
||||
("file.importPresets", "Import Profiles & Presets…", None, "File"),
|
||||
("file.exportPresets", "Export Presets…", None, "File"),
|
||||
// Edit panel ▸ Curve ▸ Point Curve dropdown
|
||||
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
|
||||
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
|
||||
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
|
||||
("app.openLibrary", "Open Library…", None, "File"),
|
||||
("app.about", "About LightCraft", None, "Help"),
|
||||
("app.systemInfo", "System Info…", None, "Help"),
|
||||
("app.whatsNew", "What's New", None, "Help"),
|
||||
("dialog.cull", "Assisted Culling…", None, "Photo"),
|
||||
("app.help", "LightCraft Help", Some("F1"), "Help"),
|
||||
("app.discord", "Join the ArtCraft Discord…", None, "Help"),
|
||||
("app.feedback", "Send Feedback…", None, "Help"),
|
||||
("app.website", "LightCraft Website", None, "Help"),
|
||||
("app.github", "LightCraft on GitHub", None, "Help"),
|
||||
("app.artcraft", "ArtCraft Website", None, "Help"),
|
||||
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
|
||||
("app.export", "Export Now", None, ""),
|
||||
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
|
||||
("dialog.rename", "Rename Photos…", None, "Photo"),
|
||||
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
|
||||
("dialog.labelNames", "Edit Color Label Names…", None, ""),
|
||||
("dialog.captureTime", "Edit Capture Time…", None, "Photo"),
|
||||
("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"),
|
||||
@@ -182,6 +208,44 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.view = ViewMode::SquareGrid;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.gridInfo" => {
|
||||
// {info?: filename | exposure | date} (cycles when omitted)
|
||||
let next = match p.get("info").and_then(Value::as_str) {
|
||||
Some(i @ ("filename" | "exposure" | "date")) => i.to_string(),
|
||||
Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))),
|
||||
None => match app.ui.grid_info.as_str() {
|
||||
"filename" => "exposure".into(),
|
||||
"exposure" => "date".into(),
|
||||
_ => "filename".into(),
|
||||
},
|
||||
};
|
||||
app.ui.grid_info = next;
|
||||
app.ui.show_filenames = true;
|
||||
Ok(json!({"info": app.ui.grid_info}))
|
||||
}
|
||||
"tool.keywordPainter" => {
|
||||
// {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops
|
||||
app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string);
|
||||
if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) {
|
||||
app.ui.view = ViewMode::PhotoGrid;
|
||||
}
|
||||
if let Some(k) = app.ui.keyword_painter.clone() {
|
||||
app.toast(&ctx, format!("Painting “{k}”: click photos to add or remove it · Esc stops"));
|
||||
}
|
||||
Ok(json!({"keyword": app.ui.keyword_painter}))
|
||||
}
|
||||
"view.secondWindow" => {
|
||||
app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window);
|
||||
Ok(json!({"show": app.ui.second_window}))
|
||||
}
|
||||
"view.photoCounts" => {
|
||||
app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts);
|
||||
Ok(json!({"show": app.ui.show_counts}))
|
||||
}
|
||||
"view.gridToggle" => {
|
||||
app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid };
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.detail" => {
|
||||
app.ui.view = ViewMode::Detail;
|
||||
Ok(Value::Null)
|
||||
@@ -191,6 +255,30 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.view = ViewMode::Survey;
|
||||
Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()}))
|
||||
}
|
||||
"view.reference" => {
|
||||
// the reference: the one set before, else the active photo (the next one becomes active)
|
||||
let vis = app.session.visible_cloned();
|
||||
let reference = app
|
||||
.ui
|
||||
.reference
|
||||
.filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some())
|
||||
.or_else(|| app.session.active().map(|a| a.0));
|
||||
let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) };
|
||||
app.ui.reference = Some(r);
|
||||
if app.session.active().is_none_or(|a| a.0 == r)
|
||||
&& let Some(i) = vis.iter().position(|x| x.0 == r)
|
||||
&& let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j)))
|
||||
{
|
||||
let _ = app.session.execute("library.select", &json!({"ids": [next.0]}));
|
||||
}
|
||||
app.ui.view = ViewMode::Reference;
|
||||
Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)}))
|
||||
}
|
||||
"photo.setReference" => {
|
||||
let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0));
|
||||
app.ui.reference = id;
|
||||
Ok(json!({"reference": id}))
|
||||
}
|
||||
"compare.swap" => crate::panels::compare::swap(app),
|
||||
"compare.makeSelect" => crate::panels::compare::make_select(app),
|
||||
"view.autoAdvance" => {
|
||||
@@ -201,8 +289,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"view.back" => {
|
||||
if app.ui.dialog.is_some() {
|
||||
app.ui.dialog = None;
|
||||
} else if app.ui.keyword_painter.is_some() {
|
||||
app.ui.keyword_painter = None;
|
||||
} else if app.ui.fullscreen {
|
||||
app.ui.fullscreen = false;
|
||||
app.ui.slideshow = None;
|
||||
} else if !app.ui.tool.is_empty() {
|
||||
app.ui.tool.clear();
|
||||
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
|
||||
@@ -212,8 +303,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.slideshow" => {
|
||||
// {interval?: seconds (4)}: the photos in view, full screen, one after another
|
||||
let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0);
|
||||
if app.session.active().is_none() {
|
||||
let first = app.session.visible_cloned().first().copied();
|
||||
match first {
|
||||
Some(f) => {
|
||||
let _ = app.run("library.select", json!({"ids": [f.0]}));
|
||||
}
|
||||
None => return Some(Err("no photos to show".into())),
|
||||
}
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
app.ui.slideshow = Some((interval, now + interval, false));
|
||||
app.ui.fullscreen = true;
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
app.ui.tool.clear();
|
||||
let _ = app.session.end_interaction();
|
||||
app.toast(&ctx, "Slideshow · Space pauses · Esc ends");
|
||||
Ok(json!({"interval": interval}))
|
||||
}
|
||||
"view.fullScreenPreview" => {
|
||||
app.ui.fullscreen = !app.ui.fullscreen;
|
||||
if !app.ui.fullscreen {
|
||||
app.ui.slideshow = None;
|
||||
}
|
||||
if app.ui.fullscreen {
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
app.ui.tool.clear();
|
||||
@@ -293,9 +408,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.zoomToggle" => {
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
|
||||
// the same ratio a click on the photo zooms to
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit };
|
||||
app.ui.zoom_anim = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.clickZoom" => {
|
||||
// {ratio?: 1|2|3|4|8} → {ratio}
|
||||
if let Some(r) = p.get("ratio").and_then(Value::as_f64) {
|
||||
let pct = (r * 100.0).round() as u32;
|
||||
if !crate::state::CLICK_ZOOMS.contains(&pct) {
|
||||
return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)")));
|
||||
}
|
||||
app.ui.click_zoom = pct;
|
||||
}
|
||||
Ok(json!({"ratio": app.ui.click_zoom / 100}))
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let steps = [25u32, 50, 100, 200, 400, 800];
|
||||
let cur = match app.ui.zoom {
|
||||
@@ -314,6 +442,32 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.show_clipping = !app.ui.show_clipping;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.softProof" => {
|
||||
// {on?, space?, destWarning?, displayWarning?}; no params toggles
|
||||
let has = |k: &str| p.get(k).is_some();
|
||||
if let Some(s) = p.get("space").and_then(Value::as_str) {
|
||||
match lightcraft_engine::pipeline::OutputSpace::parse(s) {
|
||||
Some(sp) => app.ui.proof.space = sp,
|
||||
None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))),
|
||||
}
|
||||
}
|
||||
if let Some(b) = p.get("destWarning").and_then(Value::as_bool) {
|
||||
app.ui.proof.dest_warning = b;
|
||||
}
|
||||
if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) {
|
||||
app.ui.proof.display_warning = b;
|
||||
}
|
||||
app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) {
|
||||
Some(b) => b,
|
||||
None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof,
|
||||
None => !app.ui.soft_proof,
|
||||
};
|
||||
if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) {
|
||||
app.ui.view = ViewMode::Detail;
|
||||
}
|
||||
let pr = app.ui.proof;
|
||||
Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning}))
|
||||
}
|
||||
"view.histogram" => {
|
||||
app.ui.histogram = !app.ui.histogram;
|
||||
Ok(Value::Null)
|
||||
@@ -460,6 +614,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"tool.done" => {
|
||||
// Return commits a tool panel (crop, remove, red eye, masking): back to Edit
|
||||
use RightPanel::*;
|
||||
if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) {
|
||||
let _ = app.session.end_interaction();
|
||||
app.ui.tool.clear();
|
||||
app.ui.right = Edit;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"panel.close" => {
|
||||
app.ui.right = RightPanel::None;
|
||||
app.ui.presets = false;
|
||||
@@ -477,6 +641,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
let tool = &s["tool.".len()..];
|
||||
match tool {
|
||||
"none" => app.ui.tool.clear(),
|
||||
"guidedUpright" => {
|
||||
// Crop & Geometry with Guided Upright on, ready to draw guides
|
||||
app.ui.right = RightPanel::Crop;
|
||||
app.ui.view = ViewMode::Detail;
|
||||
let guided = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|id| app.session.develop_of(id))
|
||||
.is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided);
|
||||
if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) {
|
||||
return Some(Err(e));
|
||||
}
|
||||
app.ui.tool = "guidedUpright".into();
|
||||
}
|
||||
"brush" => {
|
||||
app.ui.right = RightPanel::Masking;
|
||||
app.ui.view = ViewMode::Detail;
|
||||
@@ -509,6 +687,35 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.allMetadata" => {
|
||||
let r = match app.session.execute("photo.allMetadata", p) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(Err(e.to_string())),
|
||||
};
|
||||
let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.smartAlbum" => {
|
||||
// {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3
|
||||
let album = p
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned());
|
||||
app.ui.dialog = Some(match album {
|
||||
Some(a) => Dialog::SmartRules {
|
||||
id: Some(a.id.0),
|
||||
name: a.name.clone(),
|
||||
rules: a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(),
|
||||
},
|
||||
None => Dialog::SmartRules {
|
||||
id: None,
|
||||
name: p.get("name").and_then(Value::as_str).unwrap_or("").into(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
},
|
||||
});
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.newSmartAlbum" => {
|
||||
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
|
||||
Ok(Value::Null)
|
||||
@@ -523,7 +730,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"dialog.labelNames" => {
|
||||
let names =
|
||||
lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect();
|
||||
app.ui.dialog = Some(Dialog::LabelNames { names });
|
||||
app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.rename" => {
|
||||
@@ -573,6 +780,64 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::About);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"photo.editInExternal" => {
|
||||
// render an edit copy (stacked on the original), then open it in the editor
|
||||
let mut params = p.clone();
|
||||
if !params.is_object() {
|
||||
params = json!({});
|
||||
}
|
||||
let r = match app.session.execute("photo.editExternal", ¶ms) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(Err(e.to_string())),
|
||||
};
|
||||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||||
if let Some(id) = r["id"].as_u64() {
|
||||
app.ui.external_edits.push(id);
|
||||
}
|
||||
let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone());
|
||||
if let Some(f) = app.services.open_with.as_mut()
|
||||
&& let Err(e) = f(&path, &editor)
|
||||
{
|
||||
app.toast(&ctx, format!("Couldn't open the editor: {e}"));
|
||||
}
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
|
||||
Ok(r)
|
||||
}
|
||||
"dialog.cull" => {
|
||||
app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.whatsNew" => {
|
||||
app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
Ok(json!({"text": crate::panels::dialogs::WHATS_NEW}))
|
||||
}
|
||||
"app.systemInfo" => {
|
||||
let info = app.session.execute("library.info", &json!({})).unwrap_or_default();
|
||||
let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten();
|
||||
let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64);
|
||||
let mut rows = vec![
|
||||
("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
|
||||
("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)),
|
||||
("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())),
|
||||
("GPU".to_string(), gpu.unwrap_or_else(|| "none (CPU rendering)".into())),
|
||||
("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }),
|
||||
("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)),
|
||||
("Preview size".to_string(), format!("{} px", app.ui.settings.preview_edge)),
|
||||
("Photos".to_string(), info["photos"].to_string()),
|
||||
("Albums".to_string(), info["albums"].to_string()),
|
||||
];
|
||||
if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) {
|
||||
rows.push(("Library".into(), dir.to_string()));
|
||||
}
|
||||
rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps)));
|
||||
rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms)));
|
||||
let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>());
|
||||
if p.get("open").and_then(Value::as_bool).unwrap_or(true) {
|
||||
app.ui.dialog = Some(Dialog::SystemInfo { rows });
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
"app.shortcuts" => {
|
||||
app.ui.dialog = Some(Dialog::Shortcuts);
|
||||
Ok(Value::Null)
|
||||
@@ -592,6 +857,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.quit = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.saveMetadataPreset" => {
|
||||
// from the active photo's copyright, creator and place
|
||||
crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name");
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.findMissing" => {
|
||||
let folder = match p.get("folder").and_then(Value::as_str) {
|
||||
Some(f) => Some(f.to_string()),
|
||||
@@ -627,8 +897,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
},
|
||||
};
|
||||
let r = crate::import::open(app, vec![path]);
|
||||
if let Some(crate::state::Dialog::Import { opts }) = &mut app.ui.dialog {
|
||||
opts.copy = true;
|
||||
// only the scan just started (an error means another scan is running)
|
||||
if r.is_ok()
|
||||
&& let Some(t) = &mut app.scan
|
||||
{
|
||||
t.copy = true;
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -654,15 +927,54 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = app.session.execute("preset.import", &json!({"paths": paths})).map_err(|e| e.to_string());
|
||||
// .cube LUTs (and those inside zips / folders) become profiles
|
||||
let lut_paths: Vec<&String> = paths
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let l = p.to_ascii_lowercase();
|
||||
l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir()
|
||||
})
|
||||
.collect();
|
||||
let profiles = if lut_paths.is_empty() {
|
||||
0
|
||||
} else {
|
||||
app.session
|
||||
.execute("profile.import", &json!({"paths": lut_paths}))
|
||||
.ok()
|
||||
.and_then(|v| v["imported"].as_array().map(Vec::len))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect();
|
||||
let r = if preset_paths.is_empty() {
|
||||
Ok(json!({"imported": [], "failed": [], "skipped": 0}))
|
||||
} else {
|
||||
app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string())
|
||||
};
|
||||
if profiles > 0 {
|
||||
app.toast(&ctx, format!("Imported {profiles} profile{} (Profile browser ▸ their groups)", if profiles == 1 { "" } else { "s" }));
|
||||
}
|
||||
if let Ok(v) = &r {
|
||||
let n = v["imported"].as_array().map_or(0, Vec::len);
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
let msg = match (n, failed) {
|
||||
let mut msg = match (n, failed) {
|
||||
(0, 0) => "No new presets".to_string(),
|
||||
(n, 0) => format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }),
|
||||
(n, f) => format!("Imported {n} preset{}, {f} file{} not readable", if n == 1 { "" } else { "s" }, if f == 1 { "" } else { "s" }),
|
||||
};
|
||||
// settings with no counterpart here (the other editor's profiles, masks…)
|
||||
let mut skipped: Vec<&str> = v["imported"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|i| i["unmapped"].as_array().into_iter().flatten())
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
skipped.sort_unstable();
|
||||
skipped.dedup();
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
|
||||
msg += &format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
|
||||
}
|
||||
app.toast(&ctx, msg);
|
||||
if n > 0 {
|
||||
app.ui.presets = true;
|
||||
@@ -696,9 +1008,51 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.importCurvePresets" => {
|
||||
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => match app.services.pick_curve_preset_files.as_mut() {
|
||||
Some(f) => f(),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string());
|
||||
if let Ok(v) = &r {
|
||||
let n = v["imported"].as_array().map_or(0, Vec::len);
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" });
|
||||
if failed > 0 {
|
||||
msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" });
|
||||
}
|
||||
app.toast(&ctx, msg);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.exportCurvePresets" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => match app.services.save_curve_preset_file.as_mut() {
|
||||
Some(f) => f("Point Curves.lccurve"),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = json!({"path": path});
|
||||
if let Some(n) = p.get("names") {
|
||||
params["names"] = n.clone();
|
||||
}
|
||||
let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string());
|
||||
if let Ok(v) = &r {
|
||||
app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"app.export" => crate::control::export_active(app, p),
|
||||
"app.showInFinder" => show_in_finder(app),
|
||||
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" => {
|
||||
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => {
|
||||
let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE);
|
||||
crate::links::open(app, url)
|
||||
}
|
||||
@@ -728,6 +1082,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
|
||||
}
|
||||
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user