Compare commits
No files matched your search
@@ -17,6 +17,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
|
||||
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
|
||||
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
|
||||
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
|
||||
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
@@ -31,6 +59,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.
|
||||
|
||||
@@ -17,6 +17,34 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
([storytold/craftrules](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)).
|
||||
- **Non-test code never panics:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`, and
|
||||
no `unsafe`. Return the crate's error type through `Result` and `?`; use `ok_or(..)?`, `let … else`, `if let`, or a
|
||||
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
|
||||
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
|
||||
+ `.expect("why it can't fail")`.
|
||||
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
|
||||
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
|
||||
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
|
||||
fallback where possible, and a line here naming it.
|
||||
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
|
||||
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
|
||||
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.
|
||||
- **Bound recursion** (depth limits or seen-sets: IFD chains, nested metadata, collections). **Don't cascade:**
|
||||
`lock().unwrap_or_else(PoisonError::into_inner)` or an error; worker-thread joins are `Result`s.
|
||||
- **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped
|
||||
panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native.
|
||||
- **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix.
|
||||
- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate
|
||||
root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit
|
||||
integration tests, examples and benches. New crates start with the attribute.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
@@ -31,6 +59,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
+28
-21
@@ -2073,13 +2073,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
@@ -2090,7 +2091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -2099,11 +2100,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -2111,13 +2113,14 @@ dependencies = [
|
||||
"lightcraft-mcp",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-ui-egui",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
@@ -2142,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -2151,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -2162,7 +2165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"libc",
|
||||
@@ -2189,7 +2192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2197,7 +2200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -2213,7 +2216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -2224,7 +2227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -2240,7 +2243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -2252,7 +2255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2268,7 +2271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -2280,7 +2283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -2292,7 +2295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-color",
|
||||
@@ -2311,7 +2314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2320,9 +2323,13 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2331,7 +2338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
@@ -2351,7 +2358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5119,7 +5126,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
lightcraft-tiff = { path = "crates/tiff" }
|
||||
|
||||
+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.
|
||||
@@ -347,6 +347,9 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
|
||||
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
|
||||
(one task id per commit).
|
||||
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
|
||||
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
|
||||
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
|
||||
|
||||
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).
|
||||
|
||||
@@ -396,12 +399,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>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-mcp = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Give memory the system allocator keeps after frees back to the system
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]).
|
||||
//!
|
||||
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
|
||||
//! process's memory footprint but stay in its resident size until the system runs short. After a
|
||||
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
|
||||
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
|
||||
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
|
||||
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
|
||||
|
||||
/// Install the hook for this platform.
|
||||
/// Install the hook.
|
||||
pub fn install() {
|
||||
#[cfg(target_os = "macos")]
|
||||
lightcraft_engine::memory::set_release_hook(macos::release);
|
||||
lightcraft_engine::memory::set_release_hook(release);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
mod macos {
|
||||
unsafe extern "C" {
|
||||
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
|
||||
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
|
||||
pub fn release() {
|
||||
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
|
||||
// it only returns free pages to the system and touches no memory we own.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
fn release() {
|
||||
let _ = lightcraft_sysmem::release_free_memory();
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
|
||||
@@ -283,14 +285,18 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
|
||||
let mut written = Vec::new();
|
||||
match kind {
|
||||
"hdr" => {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
|
||||
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
|
||||
std::fs::write(&p, b).map_err(|e| e.to_string())?;
|
||||
written.push(p);
|
||||
}
|
||||
}
|
||||
"panorama" | "pano" => {
|
||||
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() {
|
||||
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let p = Path::new(&dir).join(format!("view-{k}.png"));
|
||||
let img = v.to_srgb8();
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
|
||||
//! persistence and `?workers=0` renders on the main thread.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
|
||||
@@ -29,17 +29,18 @@ const LIBRARY_DIR: &str = "library";
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
|
||||
fn window() -> web_sys::Window {
|
||||
web_sys::window().expect("no window")
|
||||
/// The page's window (`None` only outside a browser page, e.g. in a worker).
|
||||
fn window() -> Option<web_sys::Window> {
|
||||
web_sys::window()
|
||||
}
|
||||
|
||||
/// Milliseconds since navigation start.
|
||||
fn perf_now() -> f64 {
|
||||
window().performance().map(|p| p.now()).unwrap_or(0.0)
|
||||
window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn set_status(text: &str) {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
el.set_text_content(Some(text));
|
||||
}
|
||||
}
|
||||
@@ -59,7 +60,7 @@ struct Options {
|
||||
|
||||
impl Options {
|
||||
fn from_url() -> Options {
|
||||
let q = window().location().search().unwrap_or_default();
|
||||
let q = window().and_then(|w| w.location().search().ok()).unwrap_or_default();
|
||||
let params: Vec<(String, String)> = q
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
@@ -102,7 +103,7 @@ async fn read_file(originals: Originals, backend: Option<Backend>, file: web_sys
|
||||
|
||||
/// Show the browser's open dialog; picked files are imported asynchronously.
|
||||
fn open_picker(originals: Originals, backend: Option<Backend>, ctx: egui::Context) {
|
||||
let Some(doc) = window().document() else { return };
|
||||
let Some(doc) = window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_multiple(true);
|
||||
@@ -131,7 +132,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
opts.set_type(crate::store::mime_for(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
|
||||
let doc = window().document().ok_or("no document")?;
|
||||
let doc = window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
@@ -140,7 +141,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
if let Some(w) = window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
}
|
||||
log::info!("exported {name} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -153,7 +156,10 @@ 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,
|
||||
pick_tracklog: 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,
|
||||
@@ -313,7 +319,7 @@ impl WebApp {
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().navigator().hardware_concurrency() as usize;
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let workers =
|
||||
@@ -514,8 +520,10 @@ pub fn start() {
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) =
|
||||
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
log::error!("missing <canvas id=\"lightcraft_canvas\">");
|
||||
return;
|
||||
@@ -525,7 +533,7 @@ pub fn start() {
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Give memory the system allocator keeps after frees back to the system
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]).
|
||||
//!
|
||||
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
|
||||
//! process's memory footprint but stay in its resident size until the system runs short. After a
|
||||
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
|
||||
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
|
||||
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
|
||||
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
|
||||
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
|
||||
|
||||
/// Install the hook for this platform.
|
||||
/// Install the hook.
|
||||
pub fn install() {
|
||||
#[cfg(target_os = "macos")]
|
||||
lightcraft_engine::memory::set_release_hook(macos::release);
|
||||
lightcraft_engine::memory::set_release_hook(release);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(unsafe_code)]
|
||||
mod macos {
|
||||
unsafe extern "C" {
|
||||
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
|
||||
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
|
||||
pub fn release() {
|
||||
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
|
||||
// it only returns free pages to the system and touches no memory we own.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
fn release() {
|
||||
let _ = lightcraft_sysmem::release_free_memory();
|
||||
}
|
||||
@@ -12,6 +12,13 @@
|
||||
//! `--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")]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
@@ -161,13 +168,21 @@ fn services() -> Services {
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "cube"])
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Presets")
|
||||
@@ -176,6 +191,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())?;
|
||||
@@ -240,6 +273,7 @@ OPTIONS:
|
||||
";
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
alloc_release::install();
|
||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -15,6 +15,7 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Catalog persistence costs on large synthetic catalogs (issue #37): log append, compaction
|
||||
//! (snapshot serialise / write+fsync / log reset), synchronous and on the background worker, load,
|
||||
//! and a catalog clone.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p lightcraft-catalog --example persist_bench -- <dir> [photos…]
|
||||
//! ```
|
||||
//!
|
||||
//! `<dir>` is a scratch directory (wiped per size). Default sizes: 1000 10000 85000. 90 % of the
|
||||
//! photos are Local browse records, the rest library photos with a few edits and history steps.
|
||||
//! Peak memory: run under `/usr/bin/time -l` (macOS) / `-v` (Linux) with one size;
|
||||
//! `PERSIST_BENCH_NO_LOAD=1` skips the final load, so the peak is building + compacting.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::*;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
fn photo(c: &mut Catalog, i: u64) -> Photo {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(
|
||||
id,
|
||||
Source::File { path: format!("/Users/someone/Pictures/2026/09/{:05}/IMG_{i:05}.CR3", i / 500) },
|
||||
&format!("IMG_{i:05}.CR3"),
|
||||
"CR3",
|
||||
6000,
|
||||
4000,
|
||||
"2026-09-30T12:00:00",
|
||||
);
|
||||
p.kind = MediaKind::Raw;
|
||||
p.file_size = 25_000_000 + i;
|
||||
p.captured = Some(format!("2026-09-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
|
||||
p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
|
||||
p.meta.camera = "Camera Model X".into();
|
||||
p.meta.lens = "24-70mm f/2.8".into();
|
||||
p.meta.focal_mm = Some(35.0);
|
||||
p.meta.aperture = Some(4.0);
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.as_shot_wb = Some((5200.0, 3.0));
|
||||
p.local = !i.is_multiple_of(10);
|
||||
if !p.local {
|
||||
let mut d = DevelopSettings::default();
|
||||
d.light.exposure = (i % 7) as f64 * 0.1;
|
||||
let s = Arc::new(d);
|
||||
p.develop = s.clone();
|
||||
p.edited = Some("2026-09-30T12:00:00".into());
|
||||
p.history = vec![HistoryStep { label: "Exposure".into(), settings: s }];
|
||||
p.rating = (i % 6) as u8;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn stats(mut v: Vec<f64>) -> String {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let pick = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
||||
format!("median {:.2} ms, p95 {:.2} ms, max {:.2} ms", pick(0.5), pick(0.95), pick(1.0))
|
||||
}
|
||||
|
||||
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let dir = std::path::PathBuf::from(args.next().ok_or("usage: persist_bench <scratch dir> [photos…]")?);
|
||||
let mut sizes: Vec<u64> = args.map(|a| a.parse()).collect::<std::result::Result<_, _>>()?;
|
||||
if sizes.is_empty() {
|
||||
sizes = vec![1000, 10_000, 85_000];
|
||||
}
|
||||
for n in sizes {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, _, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
let mut cat = Catalog::new();
|
||||
// bulk-add (as one import would) and log it, so the first compaction is an import's
|
||||
for chunk in (0..n).collect::<Vec<_>>().chunks(500) {
|
||||
let ops: Vec<Op> = chunk.iter().map(|i| Op::AddPhoto { photo: Box::new(photo(&mut cat, *i)) }).collect();
|
||||
for op in &ops {
|
||||
cat.apply(op.clone())?;
|
||||
}
|
||||
j.append(&ops)?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let first = j.stats().last_snapshot;
|
||||
println!("== {n} photos ({} local): snapshot {:.1} MB", cat.photos().filter(|p| p.local).count(), first.bytes as f64 / 1e6);
|
||||
|
||||
let t = Instant::now();
|
||||
let copy = cat.clone();
|
||||
println!(" catalog clone: {:.2} ms", ms(t));
|
||||
drop(copy);
|
||||
|
||||
// single-op appends (a rating change each), as commands do
|
||||
let ids: Vec<PhotoId> = cat.photos().map(|p| p.id).take(1000).collect();
|
||||
let mut appends = Vec::new();
|
||||
for k in 0..300usize {
|
||||
let id = ids.get(k % ids.len().max(1)).copied().unwrap_or(PhotoId(1));
|
||||
let op = Op::SetRating { id, rating: (k % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(std::slice::from_ref(&op))?;
|
||||
appends.push(j.stats().last_append_ms);
|
||||
}
|
||||
println!(" append (1 op, fsync): {}", stats(appends));
|
||||
|
||||
// compactions at the default threshold crossing
|
||||
let mut totals = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
j.snapshot(&cat)?;
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" compaction ({} records): serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms",
|
||||
s.records, s.serialize_ms, s.write_sync_ms, s.reset_ms, s.total_ms
|
||||
);
|
||||
totals.push(s.total_ms);
|
||||
}
|
||||
println!(" compaction total: {}", stats(totals));
|
||||
|
||||
// the same at the threshold, written by the worker while single-op appends go on
|
||||
let mut blocking = Vec::new();
|
||||
for _ in 0..3 {
|
||||
while !j.wants_snapshot() {
|
||||
let op = Op::SetRating { id: ids.first().copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.snapshot_in_background(&cat)?;
|
||||
let start_ms = ms(t);
|
||||
let mut during = Vec::new();
|
||||
while !j.snapshot_written() {
|
||||
let op = Op::SetRating { id: ids.get(1).copied().unwrap_or(PhotoId(1)), rating: (j.seq() % 6) as u8 };
|
||||
cat.apply(op.clone())?;
|
||||
j.append(&[op])?;
|
||||
during.push(j.stats().last_append_ms);
|
||||
}
|
||||
let t = Instant::now();
|
||||
j.poll()?;
|
||||
let finish_ms = ms(t);
|
||||
let s = j.stats().last_snapshot;
|
||||
println!(
|
||||
" background compaction ({} records): start {start_ms:.1} ms + finish {finish_ms:.1} ms blocking; worker {:.1} ms; {} appends during it: {}",
|
||||
s.records,
|
||||
s.total_ms,
|
||||
during.len(),
|
||||
stats(during)
|
||||
);
|
||||
blocking.push(s.blocking_ms);
|
||||
}
|
||||
println!(" background compaction blocking: {}", stats(blocking));
|
||||
|
||||
drop(j);
|
||||
if std::env::var_os("PERSIST_BENCH_NO_LOAD").is_some() {
|
||||
// peak memory of building + compacting only
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (_, loaded, _) = Journal::open(Box::new(FsStore::open(&dir)?))?;
|
||||
println!(" open (load snapshot + replay): {:.1} ms", ms(t));
|
||||
if loaded.len() != cat.len() {
|
||||
return Err("reloaded catalog differs".into());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 0227748e171237a13833c9175fe02c7fe6ff332266798961a6a43ec37b81d1b4 # shrinks to actions = [(3, 0), (0, 0), (4, 0), (0, 0)]
|
||||
cc 0b0ab2531bd46fbe261b55ecffebea9d3e8949900ab7bc33fb27ca19f5cd7b8b # shrinks to actions = [(4, 443), (3, 474), (2, 27), (5, 858), (5, 466), (0, 901), (4, 907), (3, 839), (0, 630), (7, 578), (4, 13), (2, 361), (6, 136), (6, 12), (6, 223), (1, 394), (6, 78), (5, 837), (6, 765), (4, 207), (2, 185), (2, 583), (2, 56), (5, 430), (1, 702), (1, 547), (5, 552), (4, 762), (0, 443), (0, 851), (7, 240), (4, 438), (3, 599), (6, 70), (4, 694), (1, 327), (7, 859), (6, 814), (4, 336), (3, 438), (0, 131), (0, 831), (3, 18), (2, 963), (6, 10), (3, 114)]
|
||||
@@ -128,13 +128,15 @@ pub fn display_time(iso: &str) -> String {
|
||||
|
||||
/// Header text for a group key.
|
||||
pub fn group_label(key: &str) -> String {
|
||||
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
|
||||
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
|
||||
// `get` rather than `[..]`: a non-ASCII key must not split a character
|
||||
let year = key.get(..4).unwrap_or(key);
|
||||
match key.len() {
|
||||
10 => match (weekday(key), month(key), key[8..10].parse::<u32>().ok()) {
|
||||
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]),
|
||||
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
|
||||
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
|
||||
_ => key.to_string(),
|
||||
},
|
||||
7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()),
|
||||
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
|
||||
4 => key.to_string(),
|
||||
_ => "Unknown Date".into(),
|
||||
}
|
||||
@@ -196,6 +198,9 @@ mod tests {
|
||||
assert_eq!(group_label("2026-02"), "February 2026");
|
||||
assert_eq!(group_label("2026"), "2026");
|
||||
assert_eq!(group_label(""), "Unknown Date");
|
||||
// non-ASCII keys of a date's length used to be sliced mid-character
|
||||
assert_eq!(group_label("2024-01é0"), "2024-01é0");
|
||||
assert_eq!(group_label("202é01"), "January 202é01");
|
||||
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
|
||||
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
|
||||
assert_eq!(shift_iso("nope", 5), None);
|
||||
|
||||
@@ -14,6 +14,19 @@
|
||||
//! the log) are skipped;
|
||||
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
|
||||
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
|
||||
//!
|
||||
//! **Background compaction** ([`Journal::snapshot_in_background`], when the store offers a
|
||||
//! [`Store::background_writer`]): a worker thread writes the snapshot of a copy of the catalog at
|
||||
//! op `N` while appends go on (durably, as always) to the same log. Once the snapshot is durable,
|
||||
//! [`Journal::poll`] atomically replaces the log with the records appended since the snapshot
|
||||
//! started (`seq > N`, kept in memory). Every crash point is covered by the rules above:
|
||||
//! - before the rename of `catalog.snap`: the old snapshot + the whole log (nothing was removed);
|
||||
//! - between the rename and the log rewrite: the new snapshot + the whole log, whose records
|
||||
//! `<= N` are skipped as stale;
|
||||
//! - after the log rewrite: the new snapshot + exactly the records `> N`.
|
||||
//!
|
||||
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
|
||||
//! first wait for it, so an older snapshot can never replace a newer one.
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::{Catalog, CatalogError, Op, Result};
|
||||
@@ -55,6 +68,65 @@ pub struct LoadReport {
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
/// per write under `LIGHTCRAFT_PROFILE`). Times are wall-clock milliseconds on the calling
|
||||
/// thread, i.e. how long the caller (the UI thread, for the app) was blocked.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersistStats {
|
||||
/// [`Journal::append`] calls that wrote something.
|
||||
pub appends: u64,
|
||||
/// Encode + write + `sync_data` of the last / slowest append.
|
||||
pub last_append_ms: f64,
|
||||
pub max_append_ms: f64,
|
||||
/// Snapshots written (compactions, plus the ones on close / repair).
|
||||
pub snapshots: u64,
|
||||
/// The last snapshot, by stage.
|
||||
pub last_snapshot: SnapshotTiming,
|
||||
/// Total time of the slowest snapshot.
|
||||
pub max_snapshot_ms: f64,
|
||||
/// Longest time a snapshot blocked the caller (for background snapshots: copying the catalog
|
||||
/// and starting the worker, plus rewriting the log when it is done).
|
||||
pub max_blocking_ms: f64,
|
||||
/// A background snapshot is being written.
|
||||
pub snapshot_running: bool,
|
||||
/// Background snapshots that failed (the log was kept whole; retried later).
|
||||
pub failed_snapshots: u64,
|
||||
}
|
||||
|
||||
/// One snapshot (compaction), by stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnapshotTiming {
|
||||
/// Serialising the catalog to JSON, streamed into the temp file (buffered writes included).
|
||||
pub serialize_ms: f64,
|
||||
/// Flushing, `sync_all` and the atomic rename of `catalog.snap`.
|
||||
pub write_sync_ms: f64,
|
||||
/// Resetting `catalog.log` (an atomic rewrite to empty, or to the records appended while a
|
||||
/// background snapshot ran; fsynced).
|
||||
pub reset_ms: f64,
|
||||
/// Start to finish (for a background snapshot: until [`Journal::poll`] saw it done).
|
||||
pub total_ms: f64,
|
||||
/// How long the caller was blocked (equal to `total_ms` unless `background`).
|
||||
pub blocking_ms: f64,
|
||||
/// Written by a worker thread.
|
||||
pub background: bool,
|
||||
/// Size of `catalog.snap`.
|
||||
pub bytes: u64,
|
||||
/// Log records the snapshot compacted.
|
||||
pub records: u64,
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print persistence timings to stderr.
|
||||
fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
||||
fn ms_since(t: web_time::Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
pub struct Journal {
|
||||
store: Box<dyn Store>,
|
||||
/// `seq` of the last durable op.
|
||||
@@ -63,6 +135,26 @@ pub struct Journal {
|
||||
log_records: u64,
|
||||
log_bytes: u64,
|
||||
pub policy: SnapshotPolicy,
|
||||
stats: PersistStats,
|
||||
/// The background snapshot in flight.
|
||||
pending: Option<Pending>,
|
||||
/// After a failed background snapshot: don't retry before the log has this many records.
|
||||
retry_at_records: u64,
|
||||
}
|
||||
|
||||
/// A background snapshot in flight.
|
||||
struct Pending {
|
||||
/// The snapshot holds the state after this op.
|
||||
seq: u64,
|
||||
/// Log bytes / records appended since it started (all `> seq`): the log once it's done.
|
||||
tail: Vec<u8>,
|
||||
tail_records: u64,
|
||||
/// Log records it compacts.
|
||||
records: u64,
|
||||
started: web_time::Instant,
|
||||
/// Time the caller spent starting it.
|
||||
start_ms: f64,
|
||||
worker: std::thread::JoinHandle<std::io::Result<SnapshotTiming>>,
|
||||
}
|
||||
|
||||
fn io(e: std::io::Error) -> CatalogError {
|
||||
@@ -90,6 +182,29 @@ pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||
}
|
||||
|
||||
/// Write `catalog.snap` for the state after op `seq`, streaming the JSON into the store (no
|
||||
/// whole-file string). The bytes are exactly
|
||||
/// `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":<serde_json of the catalog>}\n`,
|
||||
/// as before streaming. Fills in the serialise / write+sync times and the size.
|
||||
fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io::Result<SnapshotTiming> {
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut serialize_ms = 0.0;
|
||||
let bytes = store.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
use std::io::Write;
|
||||
let t = web_time::Instant::now();
|
||||
// serde_json emits many tiny writes: buffer them in a concrete writer (inlined), so only
|
||||
// 1 MiB chunks go through the store's `dyn Write`
|
||||
let mut b = std::io::BufWriter::with_capacity(1 << 20, w);
|
||||
write!(b, "{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{seq},\"catalog\":")?;
|
||||
serde_json::to_writer(&mut b, catalog).map_err(std::io::Error::other)?;
|
||||
b.write_all(b"}\n")?;
|
||||
b.flush()?;
|
||||
serialize_ms = ms_since(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
format: String,
|
||||
@@ -117,7 +232,17 @@ impl Journal {
|
||||
None => (Catalog::new(), 0),
|
||||
};
|
||||
report.snapshot_seq = snapshot_seq;
|
||||
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
|
||||
let mut j = Journal {
|
||||
store,
|
||||
seq: snapshot_seq,
|
||||
snapshot_seq,
|
||||
log_records: 0,
|
||||
log_bytes: 0,
|
||||
policy: SnapshotPolicy::default(),
|
||||
stats: PersistStats::default(),
|
||||
pending: None,
|
||||
retry_at_records: 0,
|
||||
};
|
||||
let log = log.unwrap_or_default();
|
||||
|
||||
// Scan records; `good_end` is the byte offset just past the last good record.
|
||||
@@ -190,6 +315,7 @@ impl Journal {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut buf = String::new();
|
||||
let mut seq = self.seq;
|
||||
for op in ops {
|
||||
@@ -198,28 +324,166 @@ impl Journal {
|
||||
buf.push('\n');
|
||||
}
|
||||
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
||||
if let Some(p) = self.pending.as_mut() {
|
||||
p.tail.extend_from_slice(buf.as_bytes());
|
||||
p.tail_records += ops.len() as u64;
|
||||
}
|
||||
self.seq = seq;
|
||||
self.log_records += ops.len() as u64;
|
||||
self.log_bytes += buf.len() as u64;
|
||||
let ms = ms_since(t0);
|
||||
self.stats.appends += 1;
|
||||
self.stats.last_append_ms = ms;
|
||||
self.stats.max_append_ms = self.stats.max_append_ms.max(ms);
|
||||
if profiling() {
|
||||
eprintln!("catalog: append {} op(s), {} B: {ms:.2} ms", ops.len(), buf.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
|
||||
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
|
||||
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
|
||||
// never two snapshot writers; the one in flight is older, so it must land first
|
||||
if let Err(e) = self.wait() {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut timing = write_snapshot(self.store.as_mut(), self.seq, catalog).map_err(io)?;
|
||||
let t2 = web_time::Instant::now();
|
||||
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||
timing.reset_ms = ms_since(t2);
|
||||
timing.total_ms = ms_since(t0);
|
||||
timing.blocking_ms = timing.total_ms;
|
||||
timing.records = self.log_records;
|
||||
self.record_snapshot(timing);
|
||||
self.snapshot_seq = self.seq;
|
||||
self.log_records = 0;
|
||||
self.log_bytes = 0;
|
||||
self.retry_at_records = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact like [`Journal::snapshot`], but write the snapshot on a worker thread from a copy
|
||||
/// of `catalog` (cheap: photos are shared) when the store supports it (else synchronously).
|
||||
/// `catalog` must reflect every appended op. Appends go on meanwhile; [`Journal::poll`]
|
||||
/// finishes the compaction. No-op while a snapshot is already in flight.
|
||||
pub fn snapshot_in_background(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
if self.pending.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let Some(mut writer) = self.store.background_writer() else { return self.snapshot(catalog) };
|
||||
let copy = catalog.clone();
|
||||
let seq = self.seq;
|
||||
let spawned = std::thread::Builder::new().name("catalog-snapshot".into()).spawn(move || write_snapshot(writer.as_mut(), seq, ©));
|
||||
let worker = match spawned {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("catalog: can't start a snapshot thread ({e}); writing it now");
|
||||
return self.snapshot(catalog);
|
||||
}
|
||||
};
|
||||
self.pending =
|
||||
Some(Pending { seq, tail: Vec::new(), tail_records: 0, records: self.log_records, started: t0, start_ms: ms_since(t0), worker });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish a background snapshot if its worker is done (cheap otherwise). Returns whether one
|
||||
/// finished. An error means the snapshot failed: nothing was lost (the log is whole), and
|
||||
/// compaction is retried once the log has grown by another [`SnapshotPolicy::max_records`].
|
||||
pub fn poll(&mut self) -> Result<bool> {
|
||||
if !self.pending.as_ref().is_some_and(|p| p.worker.is_finished()) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.finish().map(|()| true)
|
||||
}
|
||||
|
||||
/// Wait for the background snapshot in flight, if any, and finish it.
|
||||
pub fn wait(&mut self) -> Result<()> {
|
||||
if self.pending.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.finish()
|
||||
}
|
||||
|
||||
/// A background snapshot is in flight (until [`Journal::poll`] or [`Journal::wait`] finish it).
|
||||
pub fn snapshot_running(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
/// The background worker is done (its result not yet applied by [`Journal::poll`]).
|
||||
pub fn snapshot_written(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|p| p.worker.is_finished())
|
||||
}
|
||||
|
||||
/// Join the worker; on success replace the log with the records appended meanwhile.
|
||||
fn finish(&mut self) -> Result<()> {
|
||||
let Some(p) = self.pending.take() else { return Ok(()) };
|
||||
let t0 = web_time::Instant::now();
|
||||
let written = p.worker.join().unwrap_or_else(|_| Err(std::io::Error::other("snapshot thread panicked")));
|
||||
let mut timing = match written {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// the log still holds every record: nothing lost, retry later
|
||||
self.stats.failed_snapshots += 1;
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// `catalog.snap` (op `p.seq`) is durable; the log's records `<= p.seq` are now stale.
|
||||
// Keep exactly the ones appended since (a crash before this rewrite skips the stale ones).
|
||||
let t1 = web_time::Instant::now();
|
||||
self.snapshot_seq = p.seq;
|
||||
if let Err(e) = self.store.write_atomic(LOG, &p.tail) {
|
||||
// the log stays whole (stale records are skipped on load); compact again later
|
||||
self.retry_at_records = self.log_records + self.policy.max_records;
|
||||
log::error!("catalog: can't trim the log after a snapshot: {e}");
|
||||
return Err(io(e));
|
||||
}
|
||||
timing.reset_ms = ms_since(t1);
|
||||
timing.total_ms = ms_since(p.started);
|
||||
timing.blocking_ms = p.start_ms + ms_since(t0);
|
||||
timing.background = true;
|
||||
timing.records = p.records;
|
||||
self.record_snapshot(timing);
|
||||
self.log_records = p.tail_records;
|
||||
self.log_bytes = p.tail.len() as u64;
|
||||
self.retry_at_records = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_snapshot(&mut self, t: SnapshotTiming) {
|
||||
self.stats.snapshots += 1;
|
||||
self.stats.last_snapshot = t;
|
||||
self.stats.max_snapshot_ms = self.stats.max_snapshot_ms.max(t.total_ms);
|
||||
self.stats.max_blocking_ms = self.stats.max_blocking_ms.max(t.blocking_ms);
|
||||
if profiling() {
|
||||
eprintln!(
|
||||
"catalog: {}snapshot of {} records, {} B: serialize {:.1} ms, write+sync {:.1} ms, log reset {:.1} ms, total {:.1} ms, blocking {:.1} ms",
|
||||
if t.background { "background " } else { "" },
|
||||
t.records,
|
||||
t.bytes,
|
||||
t.serialize_ms,
|
||||
t.write_sync_ms,
|
||||
t.reset_ms,
|
||||
t.total_ms,
|
||||
t.blocking_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where persistence time went so far.
|
||||
pub fn stats(&self) -> PersistStats {
|
||||
PersistStats { snapshot_running: self.pending.is_some(), ..self.stats }
|
||||
}
|
||||
|
||||
/// The log is long enough to be worth compacting.
|
||||
pub fn wants_snapshot(&self) -> bool {
|
||||
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
|
||||
self.pending.is_none()
|
||||
&& self.log_records >= self.retry_at_records
|
||||
&& (self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes)
|
||||
}
|
||||
|
||||
pub fn seq(&self) -> u64 {
|
||||
@@ -238,3 +502,16 @@ impl Journal {
|
||||
self.store.describe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Journal {
|
||||
/// Wait for a background snapshot (never leave a writer behind that could replace a newer
|
||||
/// snapshot of a journal reopened on the same files). The log is left whole: the snapshot's
|
||||
/// records in it are skipped as stale on the next load.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.pending.take()
|
||||
&& let Ok(Err(e)) = p.worker.join()
|
||||
{
|
||||
log::error!("catalog: background snapshot failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! - **undo/redo**: the engine keeps inverse ops;
|
||||
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dates;
|
||||
pub mod journal;
|
||||
@@ -20,7 +21,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use journal::{Journal, LoadReport, SnapshotPolicy};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use model::*;
|
||||
@@ -179,6 +180,10 @@ pub enum Op {
|
||||
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 {
|
||||
@@ -285,7 +290,8 @@ impl Catalog {
|
||||
/// Number of photos shown for an album in the sources list (excludes deleted photos).
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
|
||||
// counted in place: no id list is built just for its length
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
@@ -514,7 +520,7 @@ impl Catalog {
|
||||
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 } => {
|
||||
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetContent {
|
||||
id,
|
||||
@@ -522,6 +528,7 @@ impl Catalog {
|
||||
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 } => {
|
||||
@@ -615,4 +622,6 @@ impl Catalog {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
@@ -93,6 +93,65 @@ pub enum Source {
|
||||
Demo { scene: u32 },
|
||||
}
|
||||
|
||||
/// Copyright status (IPTC / XMP Rights Management `xmpRights:Marked`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CopyrightStatus {
|
||||
/// Not stated (no `xmpRights:Marked`).
|
||||
#[default]
|
||||
Unknown,
|
||||
/// `xmpRights:Marked` = True.
|
||||
Copyrighted,
|
||||
/// `xmpRights:Marked` = False.
|
||||
PublicDomain,
|
||||
}
|
||||
|
||||
impl CopyrightStatus {
|
||||
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
|
||||
/// `unknown` / `copyrighted` / `publicDomain` (also `public domain`, `public-domain`).
|
||||
pub fn parse(s: &str) -> Option<CopyrightStatus> {
|
||||
match s.trim().to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
|
||||
"unknown" | "" => Some(CopyrightStatus::Unknown),
|
||||
"copyrighted" => Some(CopyrightStatus::Copyrighted),
|
||||
"publicdomain" => Some(CopyrightStatus::PublicDomain),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The id used by commands (`photo.setMeta`'s `copyrightStatus`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "unknown",
|
||||
CopyrightStatus::Copyrighted => "copyrighted",
|
||||
CopyrightStatus::PublicDomain => "publicDomain",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => "Unknown",
|
||||
CopyrightStatus::Copyrighted => "Copyrighted",
|
||||
CopyrightStatus::PublicDomain => "Public Domain",
|
||||
}
|
||||
}
|
||||
/// As `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
|
||||
pub fn marked(self) -> Option<bool> {
|
||||
match self {
|
||||
CopyrightStatus::Unknown => None,
|
||||
CopyrightStatus::Copyrighted => Some(true),
|
||||
CopyrightStatus::PublicDomain => Some(false),
|
||||
}
|
||||
}
|
||||
pub fn from_marked(m: Option<bool>) -> CopyrightStatus {
|
||||
match m {
|
||||
None => CopyrightStatus::Unknown,
|
||||
Some(true) => CopyrightStatus::Copyrighted,
|
||||
Some(false) => CopyrightStatus::PublicDomain,
|
||||
}
|
||||
}
|
||||
pub fn is_unknown(&self) -> bool {
|
||||
*self == CopyrightStatus::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptive + capture metadata.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -115,6 +174,13 @@ pub struct Meta {
|
||||
pub alt_text: String,
|
||||
pub extended_description: String,
|
||||
pub copyright: String,
|
||||
/// Copyright status, rights usage terms and copyright info URL (IPTC Core rights fields).
|
||||
#[serde(skip_serializing_if = "CopyrightStatus::is_unknown")]
|
||||
pub copyright_status: CopyrightStatus,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub usage_terms: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub copyright_url: String,
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
@@ -200,6 +266,12 @@ pub struct Photo {
|
||||
/// 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.
|
||||
@@ -246,8 +318,14 @@ impl Photo {
|
||||
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 {
|
||||
|
||||
@@ -174,6 +174,11 @@ impl Filter {
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock (only "in the last…" rules do).
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
@@ -346,6 +351,52 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
let mut s = path.replace('\\', "/");
|
||||
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
|
||||
s = match rest.strip_prefix("UNC/") {
|
||||
Some(unc) => format!("//{unc}"),
|
||||
None => rest.to_string(),
|
||||
};
|
||||
}
|
||||
// the part `..` can't climb above: `//server/share`, `c:` or `/`
|
||||
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
|
||||
let mut it = unc.splitn(3, '/');
|
||||
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), rest.to_string())
|
||||
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
|
||||
(s[..2].to_ascii_lowercase(), s[2..].to_string())
|
||||
} else {
|
||||
(String::new(), s.clone())
|
||||
};
|
||||
let absolute = rest.starts_with('/') || !prefix.is_empty();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for c in rest.split('/') {
|
||||
match c {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
".." if absolute => {}
|
||||
c => parts.push(c),
|
||||
}
|
||||
}
|
||||
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
|
||||
if cfg!(windows) { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
|
||||
/// (see [`folder_key`]).
|
||||
pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
let (p, r) = (folder_key(path), folder_key(root));
|
||||
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
|
||||
@@ -70,6 +70,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("location", "Location", Kind::Text),
|
||||
("creator", "Creator", Kind::Text),
|
||||
("copyright", "Copyright", Kind::Text),
|
||||
("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
|
||||
("captureDate", "Capture Date", Kind::Date),
|
||||
("importDate", "Import Date", Kind::Date),
|
||||
("editDate", "Edit Date", Kind::Date),
|
||||
@@ -311,6 +312,10 @@ impl Rule {
|
||||
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
|
||||
"creator" => text(&m.creator),
|
||||
"copyright" => text(&m.copyright),
|
||||
"copyrightStatus" => {
|
||||
let want = crate::CopyrightStatus::parse(&want);
|
||||
(want == Some(m.copyright_status)) == (op == "is")
|
||||
}
|
||||
"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),
|
||||
@@ -341,6 +346,15 @@ impl RuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
|
||||
/// same photos can enter or leave the set without any catalog change.
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rules.iter().any(|r| match r {
|
||||
Rule::Group { group } => group.depends_on_now(),
|
||||
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
|
||||
})
|
||||
}
|
||||
|
||||
/// Unknown fields or operators (for command validation), as readable messages.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
@@ -440,6 +454,13 @@ mod tests {
|
||||
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}]}));
|
||||
// copyright status (unknown until set)
|
||||
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
|
||||
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
|
||||
let mut pd = photo(2);
|
||||
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
|
||||
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
|
||||
// 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"}]}}]}));
|
||||
|
||||
@@ -277,7 +277,7 @@ mod tests {
|
||||
#[test]
|
||||
fn group_merge_set_top_remove_and_arrange() {
|
||||
let (mut c, ids) = cat(6, &[]);
|
||||
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
|
||||
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
|
||||
let op = c.group_ops(b, &[a, b, x], false).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
let s = c.stack_of(a).unwrap().clone();
|
||||
|
||||
@@ -16,12 +16,30 @@ pub trait Store: Send {
|
||||
/// Replace a file atomically: after a crash either the old or the new content is visible,
|
||||
/// never a mix (native: write temp + fsync + rename + fsync directory).
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// [`Store::write_atomic`] with streamed content: `fill` writes the new content into the
|
||||
/// given writer (an error aborts and leaves the old file). Same durability: the file is
|
||||
/// replaced only once all of it is written (native: and fsynced). Returns its length.
|
||||
///
|
||||
/// The default buffers the content in memory; [`FsStore`] streams it into the temp file, so
|
||||
/// a large snapshot is never held in memory as a whole.
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
self.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
/// Append and make durable (fsync) before returning.
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again).
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
|
||||
/// Human-readable location (diagnostics).
|
||||
fn describe(&self) -> String;
|
||||
/// Another handle on the same files that a worker thread can write `catalog.snap` through
|
||||
/// while this one keeps appending to the log (background compaction). `None` (the default):
|
||||
/// snapshots are written synchronously.
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in a directory.
|
||||
@@ -77,6 +95,32 @@ impl Store for FsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn io::Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
let tmp = self.path(&format!("{name}.tmp"));
|
||||
let written = (|| {
|
||||
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
f.sync_all()?;
|
||||
Ok(w.n)
|
||||
})();
|
||||
let n = match written {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
std::fs::rename(&tmp, self.path(name))?;
|
||||
self.sync_dir();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
|
||||
@@ -87,7 +131,7 @@ impl Store for FsStore {
|
||||
}
|
||||
self.appender = Some((name.to_string(), f));
|
||||
}
|
||||
let (_, f) = self.appender.as_mut().expect("appender just opened");
|
||||
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
|
||||
f.write_all(data)?;
|
||||
f.sync_data()
|
||||
}
|
||||
@@ -104,12 +148,36 @@ impl Store for FsStore {
|
||||
fn describe(&self) -> String {
|
||||
self.dir.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(FsStore { dir: self.dir.clone(), appender: None }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the bytes written through it.
|
||||
struct Counter<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
impl<W: io::Write> io::Write for Counter<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let k = self.inner.write(buf)?;
|
||||
self.n += k as u64;
|
||||
Ok(k)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemStore {
|
||||
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
|
||||
/// Offer a [`Store::background_writer`] (off by default: snapshots stay synchronous).
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
impl MemStore {
|
||||
@@ -145,4 +213,7 @@ impl Store for MemStore {
|
||||
fn describe(&self) -> String {
|
||||
"memory".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
|
||||
}
|
||||
}
|
||||
@@ -231,3 +231,69 @@ fn merge_results_are_recognised() {
|
||||
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);
|
||||
}
|
||||
|
||||
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
|
||||
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
|
||||
#[test]
|
||||
fn folder_identity_ignores_spelling() {
|
||||
use crate::query::{folder_key, folder_within};
|
||||
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
|
||||
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
|
||||
for (a, b) in [
|
||||
("D:/Example/Photos", "D:\\Example\\Photos"),
|
||||
("D:/Example/Photos", "d:\\Example\\Photos\\"),
|
||||
("D:\\Example/Photos", "D:/Example//Photos/."),
|
||||
("D:/Example/Photos", "D:/Example/Other/../Photos"),
|
||||
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
|
||||
("\\\\server\\share\\Photos", "//server/share/Photos/"),
|
||||
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
|
||||
("/home/me/Photos", "/home/me/Photos/"),
|
||||
("/home/me/Photos", "/home//me/./Photos"),
|
||||
("/", "//"),
|
||||
("C:\\", "c:/"),
|
||||
] {
|
||||
same(a, b);
|
||||
}
|
||||
differ("D:/Example/Photos", "D:/Example/Photos2");
|
||||
differ("D:/Example/Photos", "E:/Example/Photos");
|
||||
differ("/home/me/Photos", "/home/me");
|
||||
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
|
||||
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
|
||||
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
|
||||
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
|
||||
assert!(folder_within("/a/b", "/"));
|
||||
assert!(folder_within("C:\\x", "c:\\"));
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Background compaction (issue #37): a snapshot written by a worker while appends go on.
|
||||
//! "Crashes" are simulated by copying the files at a given moment and loading the copy: every
|
||||
//! acknowledged (appended) op must be there, in order, whatever the snapshot was doing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT};
|
||||
use crate::*;
|
||||
|
||||
/// A MemStore whose snapshot writes wait for a gate (to hold a background snapshot mid-flight)
|
||||
/// and can be made to fail.
|
||||
#[derive(Clone)]
|
||||
struct Gated {
|
||||
files: MemStore,
|
||||
gate: Arc<(Mutex<bool>, Condvar)>,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Gated {
|
||||
fn new() -> Gated {
|
||||
Gated { files: MemStore::new(), gate: Arc::new((Mutex::new(false), Condvar::new())), fail: Arc::new(AtomicBool::new(false)) }
|
||||
}
|
||||
fn set_gate(&self, open: bool) {
|
||||
*self.gate.0.lock().unwrap() = open;
|
||||
self.gate.1.notify_all();
|
||||
}
|
||||
/// The files as a crash at this moment would leave them.
|
||||
fn crash(&self) -> MemStore {
|
||||
let m = MemStore::new();
|
||||
*m.files.lock().unwrap() = self.files.files.lock().unwrap().clone();
|
||||
m
|
||||
}
|
||||
fn snapshot_seq_on_disk(&self) -> Option<u64> {
|
||||
let v: serde_json::Value = serde_json::from_slice(&self.files.get(SNAPSHOT)?).ok()?;
|
||||
v["seq"].as_u64()
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Gated {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn write_atomic_with(&mut self, name: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> std::io::Result<u64> {
|
||||
if name == SNAPSHOT {
|
||||
let (lock, cv) = &*self.gate;
|
||||
let (open, timeout) = cv.wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(20), |open| !*open).unwrap();
|
||||
if !*open || timeout.timed_out() {
|
||||
return Err(std::io::Error::other("gate never opened"));
|
||||
}
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
fill(&mut buf)?;
|
||||
if self.fail.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"gated".into()
|
||||
}
|
||||
fn background_writer(&self) -> Option<Box<dyn Store>> {
|
||||
Some(Box::new(self.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply and durably append one edit (two ops in one append every fourth time).
|
||||
fn edit(j: &mut Journal, live: &mut Catalog, k: u64) {
|
||||
let one = |live: &mut Catalog, k: u64| {
|
||||
let op = if live.len() < 4 || k.is_multiple_of(3) {
|
||||
let id = live.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "t")) }
|
||||
} else {
|
||||
Op::SetRating { id: PhotoId(1 + k % live.len() as u64), rating: (k % 6) as u8 }
|
||||
};
|
||||
live.apply(op.clone()).unwrap();
|
||||
op
|
||||
};
|
||||
let mut ops = vec![one(live, k)];
|
||||
if k.is_multiple_of(4) {
|
||||
ops.push(one(live, k / 4 + 1));
|
||||
}
|
||||
j.append(&ops).unwrap();
|
||||
}
|
||||
|
||||
/// Load the files a crash now would leave: they must hold exactly the live state.
|
||||
fn assert_recovers(g: &Gated, live: &Catalog) -> LoadReport {
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot(), "{r:?}");
|
||||
assert_eq!((r.failed, r.torn_bytes, r.damaged.clone()), (0, 0, None));
|
||||
r
|
||||
}
|
||||
|
||||
fn wait_written(j: &Journal) {
|
||||
for _ in 0..20_000 {
|
||||
if j.snapshot_written() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
panic!("background snapshot never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_snapshot_keeps_edits_made_while_it_runs() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..30 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
assert!(j.snapshot_running() && !j.wants_snapshot() && j.stats().snapshot_running);
|
||||
|
||||
// edits while the snapshot is being written are appended (durably) as usual
|
||||
let mut during = 0;
|
||||
for k in 30..50 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
// crash before the snapshot landed: no snapshot yet, the whole log replays
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed as u64), (0, j.seq()));
|
||||
|
||||
// the snapshot lands (op n); crash before the log was trimmed: its records are stale
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
for k in 50..55 {
|
||||
edit(&mut j, &mut live, k);
|
||||
during = j.seq() - n;
|
||||
}
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, during));
|
||||
|
||||
// finishing trims the log to exactly the records after the snapshot, in order
|
||||
assert!(j.poll().unwrap());
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (n, during));
|
||||
let log = String::from_utf8(g.files.get(LOG).unwrap()).unwrap();
|
||||
let seqs: Vec<u64> = log.lines().map(|l| journal::decode_record(l).unwrap().0).collect();
|
||||
assert_eq!(seqs, ((n + 1)..=j.seq()).collect::<Vec<_>>());
|
||||
assert_eq!(j.log_bytes(), log.len() as u64);
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.stale, r.replayed as u64), (n, 0, during));
|
||||
let s = j.stats();
|
||||
assert!(s.last_snapshot.background && s.last_snapshot.records == n && !s.snapshot_running, "{s:?}");
|
||||
|
||||
// and appending goes on after it
|
||||
edit(&mut j, &mut live, 99);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
j.policy = SnapshotPolicy { max_records: 10, max_bytes: u64::MAX };
|
||||
for k in 0..12 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
assert!(j.wants_snapshot());
|
||||
g.fail.store(true, Ordering::SeqCst);
|
||||
g.set_gate(true);
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
edit(&mut j, &mut live, 12);
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
assert!(g.files.get(SNAPSHOT).is_none());
|
||||
assert_recovers(&g, &live);
|
||||
// no retry storm: the next attempt waits for another `max_records` records
|
||||
assert!(!j.wants_snapshot());
|
||||
g.fail.store(false, Ordering::SeqCst);
|
||||
while !j.wants_snapshot() {
|
||||
let k = j.seq();
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
j.wait().unwrap();
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
let r = assert_recovers(&g, &live);
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (j.seq(), 0));
|
||||
}
|
||||
|
||||
/// A synchronous snapshot (close, "Optimize Library") waits for the background one, so the older
|
||||
/// snapshot can't land after (and replace) the newer one.
|
||||
#[test]
|
||||
fn sync_snapshot_waits_for_the_background_one() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..20 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
j.snapshot(&live).unwrap();
|
||||
opener.join().unwrap();
|
||||
assert!(!j.snapshot_running());
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
assert_eq!((j.snapshot_seq(), j.log_records()), (j.seq(), 0));
|
||||
assert!(g.files.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.stats().snapshots, 2);
|
||||
assert_recovers(&g, &live);
|
||||
}
|
||||
|
||||
/// Dropping the journal (e.g. reopening the library) waits for the snapshot in flight and leaves
|
||||
/// the log whole: reopening finds every op.
|
||||
#[test]
|
||||
fn dropping_the_journal_waits_for_its_snapshot() {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for k in 0..10 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let n = j.seq();
|
||||
j.snapshot_in_background(&live).unwrap();
|
||||
for k in 10..15 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
let opener = {
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
g.set_gate(true);
|
||||
})
|
||||
};
|
||||
let total = j.seq();
|
||||
drop(j);
|
||||
opener.join().unwrap();
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(n));
|
||||
let (_, c, r) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale as u64, r.replayed as u64), (n, n, total - n));
|
||||
}
|
||||
|
||||
/// On disk, with a real worker thread: a crash mid-write leaves only a temp file, which loading
|
||||
/// ignores; edits during the snapshot survive; reopening resumes appends.
|
||||
#[test]
|
||||
fn fs_store_background_snapshot() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-bg-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
for k in 0..200 {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
// a previous crash mid-snapshot left a partial temp file
|
||||
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"lightcraft-catal").unwrap();
|
||||
let (mut j2, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((c2.to_snapshot(), r.snapshot_seq), (live.to_snapshot(), 0));
|
||||
drop(j);
|
||||
let n = j2.seq();
|
||||
j2.snapshot_in_background(&live).unwrap();
|
||||
for k in 200..260 {
|
||||
edit(&mut j2, &mut live, k);
|
||||
}
|
||||
j2.wait().unwrap();
|
||||
assert_eq!((j2.snapshot_seq(), j2.log_records()), (n, j2.seq() - n));
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
edit(&mut j2, &mut live, 999);
|
||||
drop(j2);
|
||||
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!(c3.to_snapshot(), live.to_snapshot());
|
||||
assert_eq!((r.snapshot_seq, r.stale), (n, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Any interleaving of edits, background snapshots (started, landed, finished), synchronous
|
||||
/// snapshots and crashes: a crash at any point recovers exactly the acknowledged ops.
|
||||
#[test]
|
||||
fn background_compaction_never_loses_acknowledged_ops(actions in proptest::collection::vec((0u8..8, 0u64..1000), 1..60)) {
|
||||
let g = Gated::new();
|
||||
let (mut j, mut live, _) = Journal::open(Box::new(g.clone())).unwrap();
|
||||
for (ctl, k) in actions {
|
||||
match ctl {
|
||||
0..=2 => edit(&mut j, &mut live, k),
|
||||
3 => j.snapshot_in_background(&live).unwrap(),
|
||||
4 | 5 => {
|
||||
// the worker writes its snapshot; finish it now or later
|
||||
if j.snapshot_running() {
|
||||
g.set_gate(true);
|
||||
wait_written(&j);
|
||||
g.set_gate(false);
|
||||
if ctl == 4 {
|
||||
prop_assert!(j.poll().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
g.set_gate(true);
|
||||
j.snapshot(&live).unwrap();
|
||||
g.set_gate(false);
|
||||
prop_assert_eq!(g.snapshot_seq_on_disk(), Some(j.seq()));
|
||||
}
|
||||
_ => {
|
||||
// a crash now
|
||||
let (_, c, r) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
prop_assert_eq!(r.failed, 0);
|
||||
prop_assert!(r.damaged.is_none());
|
||||
}
|
||||
}
|
||||
// the snapshot on disk never covers more than what was appended
|
||||
prop_assert!(g.snapshot_seq_on_disk().unwrap_or(0) <= j.seq());
|
||||
}
|
||||
g.set_gate(true);
|
||||
let (_, c, _) = Journal::open(Box::new(g.crash())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
drop(j);
|
||||
let (_, c, _) = Journal::open(Box::new(g.files.clone())).unwrap();
|
||||
prop_assert_eq!(c.to_snapshot(), live.to_snapshot());
|
||||
}
|
||||
}
|
||||
@@ -274,3 +274,48 @@ fn fs_store_roundtrip() {
|
||||
assert_eq!(c3.to_snapshot(), c.to_snapshot());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The streamed snapshot is byte-for-byte the format written before streaming (issue #37), so
|
||||
/// existing libraries load and older builds can read new snapshots; FsStore and MemStore agree.
|
||||
#[test]
|
||||
fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
let (m, _, full) = sample_catalog_with_log();
|
||||
let (mut j, c, _) = open(&m);
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
j.snapshot(&c).unwrap();
|
||||
let legacy = format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{},\"catalog\":{}}}\n", j.seq(), c.to_snapshot());
|
||||
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
|
||||
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-stream-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// an old-style snapshot (written by a build before streaming) loads, and is rewritten the same
|
||||
std::fs::write(dir.join(SNAPSHOT), legacy.as_bytes()).unwrap();
|
||||
let (mut fj, fc, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((fc.to_snapshot(), r.snapshot_seq), (c.to_snapshot(), j.seq()));
|
||||
fj.snapshot(&fc).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), legacy.as_bytes());
|
||||
drop(fj);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A snapshot that fails while streaming (serialisation or I/O error) leaves the old file in
|
||||
/// place and removes its temp file.
|
||||
#[test]
|
||||
fn failed_streamed_write_keeps_the_old_file() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-journal-streamfail-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.write_atomic(SNAPSHOT, b"old").unwrap();
|
||||
let r = s.write_atomic_with(SNAPSHOT, &mut |w| {
|
||||
w.write_all(b"partial new content")?;
|
||||
Err(std::io::Error::other("serialisation failed"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"old");
|
||||
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||
assert_eq!(s.write_atomic_with(SNAPSHOT, &mut |w| w.write_all(b"new")).unwrap(), 3);
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -323,7 +323,9 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
|
||||
}
|
||||
// Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded.
|
||||
let px = samples
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let k = 1.0 - c[3];
|
||||
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
|
||||
let n = img.width as usize * img.height as usize;
|
||||
let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels {
|
||||
1 => (data[..n].into(), jpeg_encoder::ColorType::Luma),
|
||||
2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
|
||||
2 => (data.as_chunks::<2>().0.iter().take(n).map(|c| c[0]).collect::<Vec<_>>().into(), jpeg_encoder::ColorType::Luma),
|
||||
3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb),
|
||||
_ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba),
|
||||
};
|
||||
@@ -254,7 +254,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
}};
|
||||
}
|
||||
fn expand_ga<T: Copy>(s: &[T], n: usize) -> Vec<T> {
|
||||
s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect()
|
||||
}
|
||||
match img.samples {
|
||||
Samples::U8(s) => match ch {
|
||||
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize
|
||||
for (s, d) in src_chunks.zip(dst_chunks) {
|
||||
t.transform(s, d).ok()?;
|
||||
}
|
||||
Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
Some(out.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect())
|
||||
};
|
||||
std::panic::catch_unwind(run).ok().flatten()
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
let (model, buf, depth) = match info.pixel_format {
|
||||
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
|
||||
PixelFormat::L16 => {
|
||||
let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
(Model::Gray, Buf::U16(v), 16)
|
||||
}
|
||||
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
|
||||
@@ -226,7 +226,7 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
|
||||
let Some(list) = t.bytes(e) else { return vec![] };
|
||||
let mut out = Vec::new();
|
||||
for (i, rec) in list.chunks_exact(16).enumerate().take(16) {
|
||||
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
|
||||
if i == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ pub fn encode(
|
||||
{
|
||||
let rows: Vec<(usize, Vec<&mut [f32]>)> = {
|
||||
let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect();
|
||||
(0..height).map(|y| (y, its.iter_mut().map(|it| it.next().unwrap()).collect())).collect()
|
||||
(0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::<Option<_>>()?))).collect()
|
||||
};
|
||||
let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| {
|
||||
let src = &data[y * width * channels..(y + 1) * width * channels];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
pub mod encode;
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
};
|
||||
buf.truncate(out.buffer_size());
|
||||
let (samples, depth) = match out.bit_depth {
|
||||
png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
|
||||
png::BitDepth::Sixteen => (Buf::U16(buf.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16),
|
||||
_ => (Buf::U8(buf), info.bit_depth as u8),
|
||||
};
|
||||
let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth };
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//!
|
||||
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cct;
|
||||
pub mod perceptual;
|
||||
@@ -125,14 +126,17 @@ pub const WORKING: RgbSpace = REC2020;
|
||||
|
||||
impl RgbSpace {
|
||||
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
|
||||
/// Degenerate (collinear) primaries, which readers reject before building a space from a
|
||||
/// file, leave the primaries unscaled instead of panicking.
|
||||
pub fn to_xyz(&self) -> Mat3 {
|
||||
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
|
||||
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
|
||||
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
|
||||
let s = m.inverse().map_or([1.0; 3], |inv| inv.apply(self.white.to_xyz()));
|
||||
m.mul(&Mat3::diag(s[0], s[1], s[2]))
|
||||
}
|
||||
/// Inverse of [`to_xyz`](Self::to_xyz); the identity for degenerate primaries.
|
||||
pub fn from_xyz(&self) -> Mat3 {
|
||||
self.to_xyz().inverse().expect("invertible")
|
||||
self.to_xyz().inverse().unwrap_or(Mat3::IDENTITY)
|
||||
}
|
||||
/// Luminance weights (the Y row of `to_xyz`).
|
||||
pub fn luma(&self) -> [f64; 3] {
|
||||
@@ -154,7 +158,8 @@ pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||
}
|
||||
let s = BRADFORD.apply(src.to_xyz());
|
||||
let d = BRADFORD.apply(dst.to_xyz());
|
||||
let inv = BRADFORD.inverse().expect("invertible");
|
||||
// a constant, invertible matrix: the fallback (no adaptation) can't happen
|
||||
let Some(inv) = BRADFORD.inverse() else { return Mat3::IDENTITY };
|
||||
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,10 @@ fn mats() -> &'static OkMats {
|
||||
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
|
||||
OkMats {
|
||||
to_lms: to_lms.to_f32(),
|
||||
from_lms: to_lms.inverse().expect("invertible").to_f32(),
|
||||
// constant, invertible matrices (round trips tested below): the identity fallback can't happen
|
||||
from_lms: to_lms.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
|
||||
to_lab: LMS_TO_LAB.to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().unwrap_or(Mat3::IDENTITY).to_f32(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
|
||||
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod controls;
|
||||
pub mod presets;
|
||||
|
||||
@@ -148,6 +148,24 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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> {
|
||||
@@ -164,12 +182,11 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 };
|
||||
if info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) {
|
||||
continue;
|
||||
}
|
||||
ops.push(Op::SetContent { id, width: info.width, height: info.height, file_size: info.file_size, content_hash: info.content_hash });
|
||||
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);
|
||||
@@ -245,7 +262,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor) → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
||||
//! metadata, albums, import.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
@@ -250,13 +250,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
||||
Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default())
|
||||
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
||||
}),
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
|
||||
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
|
||||
"copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url,
|
||||
"creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
||||
"keywords": m.keywords});
|
||||
s.meta_clipboard = Some(v.clone());
|
||||
Ok(v)
|
||||
@@ -266,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste Metadata",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, fields?: [title|caption|copyright|creator|location|keywords] (default: all copied)}",
|
||||
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let c = "photo.pasteMetadata";
|
||||
@@ -370,7 +371,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Info",
|
||||
[],
|
||||
None,
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let strs =
|
||||
@@ -390,6 +391,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
|
||||
};
|
||||
let status = match str_param(p, "copyrightStatus") {
|
||||
Some(t) => Some(
|
||||
CopyrightStatus::parse(t)
|
||||
.ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
@@ -397,10 +405,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(g) = gps {
|
||||
m.gps = g;
|
||||
}
|
||||
if let Some(st) = status {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
for (k, field) in [
|
||||
("title", &mut m.title),
|
||||
("caption", &mut m.caption),
|
||||
("copyright", &mut m.copyright),
|
||||
("usageTerms", &mut m.usage_terms),
|
||||
("copyrightUrl", &mut m.copyright_url),
|
||||
("creator", &mut m.creator),
|
||||
("location", &mut m.location),
|
||||
("city", &mut m.city),
|
||||
@@ -718,7 +731,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");
|
||||
@@ -732,10 +745,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"library.import",
|
||||
"Add Photos…",
|
||||
"Import 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/), 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?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -745,7 +758,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
|
||||
"move" => crate::import::ImportMode::Move,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
||||
};
|
||||
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
||||
Some(id) => {
|
||||
@@ -760,11 +774,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
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)")))?
|
||||
}
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad(
|
||||
"library.import",
|
||||
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
|
||||
)
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
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))
|
||||
@@ -781,7 +803,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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()
|
||||
local: super::bool_or(p, "local", false),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
|
||||
@@ -845,6 +867,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
"persistence": j.stats(),
|
||||
"cache": cache,
|
||||
"load": {
|
||||
"created": lib.report.created,
|
||||
|
||||
@@ -59,9 +59,103 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸
|
||||
/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's
|
||||
/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them.
|
||||
fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
use lightcraft_meta::{DateTime, Match, parse_gpx};
|
||||
const C: &str = "photo.autoTagTracklog";
|
||||
let text = match (str_param(p, "gpx"), str_param(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?,
|
||||
(None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")),
|
||||
};
|
||||
let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?;
|
||||
// minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC)
|
||||
let offset: Option<i64> = match p.get("offset") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(o)) if o.trim().is_empty() => None,
|
||||
Some(Value::Number(h)) => {
|
||||
let h = h.as_f64().unwrap_or(0.0);
|
||||
if h.abs() > 18.0 {
|
||||
return Err(bad(C, "offset is at most ±18 hours"));
|
||||
}
|
||||
Some((h * 60.0).round() as i64)
|
||||
}
|
||||
Some(Value::String(o)) => {
|
||||
Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64)
|
||||
}
|
||||
Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")),
|
||||
};
|
||||
let max_gap = f64_or(p, "maxGap", 600.0);
|
||||
if max_gap.is_nan() || max_gap < 0.0 {
|
||||
return Err(bad(C, "maxGap must be ≥ 0 seconds"));
|
||||
}
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||||
let mut ops = Vec::new();
|
||||
let mut photos = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
if ph.meta.gps.is_some() && !replace {
|
||||
has_gps += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else {
|
||||
no_time += 1;
|
||||
continue;
|
||||
};
|
||||
// UTC seconds: the recorded zone wins, else the caller's offset
|
||||
let utc = match dt.offset_minutes {
|
||||
Some(_) => dt.unix_seconds(),
|
||||
None => {
|
||||
if offset.is_none() {
|
||||
assumed_utc += 1;
|
||||
}
|
||||
dt.unix_seconds() - offset.unwrap_or(0) * 60
|
||||
}
|
||||
} as f64
|
||||
+ dt.millis as f64 / 1000.0;
|
||||
let Some((g, m)) = log.locate(utc, max_gap) else {
|
||||
outside += 1;
|
||||
continue;
|
||||
};
|
||||
if m == Match::Interpolated {
|
||||
interpolated += 1;
|
||||
}
|
||||
let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7);
|
||||
photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }}));
|
||||
if ph.meta.gps != Some(pos) {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.gps = Some(pos);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
}
|
||||
let tagged = photos.len();
|
||||
if !bool_or(p, "dryRun", false) && !ops.is_empty() {
|
||||
s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?;
|
||||
}
|
||||
let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64));
|
||||
Ok(json!({
|
||||
"tagged": tagged,
|
||||
"interpolated": interpolated,
|
||||
"nearest": tagged - interpolated,
|
||||
"skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps},
|
||||
"assumedUtc": assumed_utc,
|
||||
"points": log.points.len(),
|
||||
"start": iso(start),
|
||||
"end": iso(end),
|
||||
"photos": photos,
|
||||
}))
|
||||
}
|
||||
|
||||
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| {
|
||||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||||
Ok(crate::rename::token_help_json())
|
||||
}),
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||||
}),
|
||||
@@ -70,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Photos",
|
||||
[],
|
||||
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",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||||
@@ -127,6 +221,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": n, "captured": out}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.autoTagTracklog",
|
||||
"Auto-Tag Photos from Tracklog",
|
||||
[],
|
||||
None,
|
||||
"{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step",
|
||||
has_selection,
|
||||
auto_tag_tracklog
|
||||
),
|
||||
cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| {
|
||||
Ok(json!(ColorLabel::ALL
|
||||
.iter()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 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 lightcraft_catalog::{CopyrightStatus, Meta};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -9,7 +9,20 @@ 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"];
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"title",
|
||||
"caption",
|
||||
"copyright",
|
||||
"copyrightStatus",
|
||||
"usageTerms",
|
||||
"copyrightUrl",
|
||||
"creator",
|
||||
"location",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"keywords",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MetadataPreset {
|
||||
@@ -23,6 +36,8 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
"title" => &mut m.title,
|
||||
"caption" => &mut m.caption,
|
||||
"copyright" => &mut m.copyright,
|
||||
"usageTerms" => &mut m.usage_terms,
|
||||
"copyrightUrl" => &mut m.copyright_url,
|
||||
"creator" => &mut m.creator,
|
||||
"location" => &mut m.location,
|
||||
"city" => &mut m.city,
|
||||
@@ -35,7 +50,11 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> {
|
||||
/// 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" {
|
||||
if k == "copyrightStatus" {
|
||||
if let Some(st) = v.as_str().and_then(CopyrightStatus::parse) {
|
||||
m.copyright_status = st;
|
||||
}
|
||||
} else 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());
|
||||
@@ -59,6 +78,9 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
|
||||
return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", "))));
|
||||
}
|
||||
if let Some(v) = o.get("copyrightStatus").filter(|v| v.as_str().and_then(CopyrightStatus::parse).is_none()) {
|
||||
return Err(bad(C, format!("copyrightStatus {v}: unknown, copyrighted or publicDomain")));
|
||||
}
|
||||
Value::Object(o.clone())
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`fields` must be an object")),
|
||||
@@ -66,17 +88,24 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 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", "copyrightStatus", "usageTerms", "copyrightUrl", "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 k == "copyrightStatus" {
|
||||
if !m.copyright_status.is_unknown() {
|
||||
o.insert(k.clone(), json!(m.copyright_status.id()));
|
||||
}
|
||||
} else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) {
|
||||
o.insert(k.clone(), json!(v.clone()));
|
||||
}
|
||||
@@ -136,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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",
|
||||
"{name, fields?: {copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default the copyright fields, creator, place)} — adds or replaces",
|
||||
always,
|
||||
save
|
||||
),
|
||||
|
||||
@@ -5,24 +5,48 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
|
||||
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
|
||||
///
|
||||
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
|
||||
/// them in the catalog, they never show in library views (Missing Photos included), and a
|
||||
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
|
||||
/// `library.missing` and Find Missing Photos all use this one rule.
|
||||
pub fn checked_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } if p.in_library() => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
|
||||
pub fn candidates(cat: &Catalog) -> Vec<String> {
|
||||
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// [`missing`] with the existence check supplied (tests count the file-system calls).
|
||||
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
|
||||
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
|
||||
}
|
||||
|
||||
/// Library photos whose original file can't be found: (id, path).
|
||||
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Vec::new();
|
||||
}
|
||||
s.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
missing_with(&s.catalog, |f| Path::new(f).exists())
|
||||
}
|
||||
|
||||
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
|
||||
/// the photos its query already narrowed to).
|
||||
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
@@ -42,7 +66,19 @@ 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));
|
||||
}
|
||||
let op = match <[Op; 1]>::try_from(ops) {
|
||||
Ok([op]) => op,
|
||||
Err(ops) => Op::Batch { ops },
|
||||
};
|
||||
s.commit("Relink Photo", op)?;
|
||||
s.media.forget(id);
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
@@ -118,6 +119,7 @@ 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());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset and zip bundles; export `.lcpreset`.
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\") 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}",
|
||||
"{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
|
||||
),
|
||||
|
||||
@@ -104,7 +104,12 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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())))?;
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if s.smart_previews_dir.is_some() {
|
||||
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
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 };
|
||||
@@ -134,6 +139,69 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"built": built, "removed": removed, "failed": failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
/// used now (a chosen folder on an unplugged drive is `available: false`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn location_json(s: &Session) -> Value {
|
||||
let dir = s.media.smart_dir.clone();
|
||||
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir));
|
||||
let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats);
|
||||
json!({
|
||||
"path": dir.as_ref().map(|d| d.to_string_lossy()),
|
||||
"default": default.map(|d| d.to_string_lossy().to_string()),
|
||||
"custom": s.smart_previews_dir.is_some(),
|
||||
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(),
|
||||
"count": count,
|
||||
"bytes": bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Show or change where smart previews are kept (per library).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviewsLocation";
|
||||
let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
|
||||
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
|
||||
if !reset && chosen.is_none() {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let default = crate::smart::dir(&lib_dir);
|
||||
let (new_dir, custom) = match chosen {
|
||||
Some(x) if !reset => {
|
||||
let d = std::path::PathBuf::from(x.trim());
|
||||
if !d.is_absolute() {
|
||||
return Err(bad(C, "path must be an absolute folder path"));
|
||||
}
|
||||
(d.clone(), (d != default).then_some(d))
|
||||
}
|
||||
_ => (default, None),
|
||||
};
|
||||
if new_dir == current {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
// the new place must work before anything moves; there is no fallback to another drive
|
||||
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
|
||||
let (held, _) = crate::smart::stats(¤t);
|
||||
let existing = match str_param(p, "existing") {
|
||||
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
|
||||
None if held == 0 => Some(crate::smart::Existing::Leave),
|
||||
None => None,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display())));
|
||||
};
|
||||
let (moved, failed) = crate::smart::migrate(¤t, &new_dir, existing);
|
||||
s.smart_previews_dir = custom;
|
||||
s.media.smart_dir = Some(new_dir);
|
||||
s.save_prefs()?;
|
||||
let mut r = location_json(s);
|
||||
r["handled"] = json!(moved);
|
||||
r["failed"] = json!(failed);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Whether photo `id` has a smart preview.
|
||||
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
|
||||
match (&s.media.smart_dir, s.catalog.photo(id)) {
|
||||
@@ -154,6 +222,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
smart
|
||||
),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cmd!(
|
||||
"library.smartPreviewsLocation",
|
||||
"Smart Previews Location",
|
||||
[],
|
||||
None,
|
||||
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}",
|
||||
always,
|
||||
smart_location
|
||||
),
|
||||
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
|
||||
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+202
-21
@@ -1,6 +1,15 @@
|
||||
//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
|
||||
//! the import review copies from them into the library.
|
||||
//!
|
||||
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the
|
||||
//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background
|
||||
//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`]
|
||||
//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans
|
||||
//! synchronously for explicit requests (`library.devices`, Add from Device without a path).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -14,6 +23,9 @@ pub struct Device {
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// How long a scan is served before [`devices`] starts another one.
|
||||
pub const MAX_AGE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS`
|
||||
/// (paths joined like `PATH`) replaces them (tests, unusual mounts).
|
||||
fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
@@ -34,21 +46,114 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds:
|
||||
/// menus ask often, and listing mounts can be slow (network volumes).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = Mutex::new(None);
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((t, d)) = c.as_ref()
|
||||
&& t.elapsed() < Duration::from_secs(2)
|
||||
{
|
||||
return d.clone();
|
||||
type Scanner = dyn Fn() -> Vec<Device> + Send + Sync;
|
||||
type Notify = dyn Fn() + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatchState {
|
||||
list: Vec<Device>,
|
||||
/// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where
|
||||
/// there are no mounted volumes either.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
scanned: Option<std::time::Instant>,
|
||||
/// A background scan is running.
|
||||
refreshing: bool,
|
||||
}
|
||||
|
||||
/// A cached device list, refreshed off the calling thread.
|
||||
pub struct DeviceWatch {
|
||||
scanner: Box<Scanner>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
max_age: Duration,
|
||||
state: Mutex<WatchState>,
|
||||
notify: Mutex<Option<Arc<Notify>>>,
|
||||
}
|
||||
|
||||
impl DeviceWatch {
|
||||
pub fn new(scanner: impl Fn() -> Vec<Device> + Send + Sync + 'static, max_age: Duration) -> Arc<Self> {
|
||||
Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) })
|
||||
}
|
||||
let d = scan();
|
||||
*c = Some((Instant::now(), d.clone()));
|
||||
d
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, WatchState> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Called (on the scanning thread) whenever a scan changes the list.
|
||||
pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) {
|
||||
*self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f));
|
||||
}
|
||||
|
||||
/// The last scan, immediately; starts a background rescan when it is stale and none is running.
|
||||
pub fn get(self: &Arc<Self>) -> Vec<Device> {
|
||||
let mut s = self.state();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) {
|
||||
s.refreshing = true;
|
||||
let me = self.clone();
|
||||
// the scan runs without the state lock; `get` only ever waits for a list clone
|
||||
let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || {
|
||||
let list = (me.scanner)();
|
||||
me.store(list);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
s.refreshing = false;
|
||||
}
|
||||
}
|
||||
s.list.clone()
|
||||
}
|
||||
|
||||
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
|
||||
pub fn scan_now(&self) -> Vec<Device> {
|
||||
let list = (self.scanner)();
|
||||
self.store(list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
fn store(&self, list: Vec<Device>) {
|
||||
let changed = {
|
||||
let mut s = self.state();
|
||||
s.refreshing = false;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.scanned = Some(std::time::Instant::now());
|
||||
}
|
||||
let changed = s.list != list;
|
||||
s.list = list;
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(f) = f {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A background scan is running.
|
||||
pub fn refreshing(&self) -> bool {
|
||||
self.state().refreshing
|
||||
}
|
||||
}
|
||||
|
||||
fn global() -> &'static Arc<DeviceWatch> {
|
||||
static WATCH: std::sync::OnceLock<Arc<DeviceWatch>> = std::sync::OnceLock::new();
|
||||
WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE))
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan,
|
||||
/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
global().get()
|
||||
}
|
||||
|
||||
/// Scan the mounted volumes now (blocking), for explicit requests.
|
||||
pub fn devices_now() -> Vec<Device> {
|
||||
global().scan_now()
|
||||
}
|
||||
|
||||
/// Run `f` whenever a scan changes the device list (the UI requests a repaint).
|
||||
pub fn on_change(f: impl Fn() + Send + Sync + 'static) {
|
||||
global().on_change(f);
|
||||
}
|
||||
|
||||
fn scan() -> Vec<Device> {
|
||||
@@ -73,10 +178,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
roots.extend(extra);
|
||||
let mut out: Vec<Device> = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(rd) = std::fs::read_dir(&root) else { continue };
|
||||
let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dcim) = dcim_in(&root) else { continue };
|
||||
let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string());
|
||||
if !out.iter().any(|d| d.root == root.to_string_lossy()) {
|
||||
out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() });
|
||||
@@ -85,9 +187,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
out
|
||||
}
|
||||
|
||||
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
|
||||
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
|
||||
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
|
||||
return Some(p);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn volumes_with_a_dcim_folder_are_devices() {
|
||||
@@ -96,10 +211,76 @@ mod tests {
|
||||
std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap();
|
||||
std::fs::create_dir_all(base.join("Backup/Photos")).unwrap();
|
||||
std::fs::create_dir_all(base.join("SD/dcim")).unwrap();
|
||||
std::fs::create_dir_all(base.join("X/Dcim")).unwrap();
|
||||
let d = devices_in(std::slice::from_ref(&base), Vec::new());
|
||||
let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}");
|
||||
assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim"));
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}");
|
||||
// the DCIM folder itself, in a spelling that opens on that volume
|
||||
for x in &d {
|
||||
let p = std::path::Path::new(&x.path);
|
||||
assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
fn card(name: &str) -> Device {
|
||||
Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
|
||||
}
|
||||
|
||||
/// Wait (bounded) for `f`.
|
||||
fn until(f: impl Fn() -> bool) -> bool {
|
||||
let t = std::time::Instant::now();
|
||||
while t.elapsed() < Duration::from_secs(20) {
|
||||
if f() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_scans_never_block_and_hot_plug_still_arrives() {
|
||||
// a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
|
||||
const SLOW: Duration = Duration::from_millis(1500);
|
||||
let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
|
||||
let runs = Arc::new(AtomicUsize::new(0));
|
||||
let (m, r) = (mounted.clone(), runs.clone());
|
||||
let watch = DeviceWatch::new(
|
||||
move || {
|
||||
r.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(SLOW);
|
||||
m.lock().unwrap().clone()
|
||||
},
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
let changes = Arc::new(AtomicUsize::new(0));
|
||||
let c = changes.clone();
|
||||
watch.on_change(move || {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
// cold: served at once (empty) while one scan runs in the background
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..50 {
|
||||
assert!(watch.get().is_empty());
|
||||
}
|
||||
assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
|
||||
assert!(watch.refreshing());
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
|
||||
// a card is plugged in: the next stale request starts a rescan, still without waiting
|
||||
mounted.lock().unwrap().push(card("EOS_DIGITAL"));
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
let t = std::time::Instant::now();
|
||||
assert!(watch.get().is_empty(), "the old list is served while rescanning");
|
||||
assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
|
||||
assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
|
||||
assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
|
||||
// explicit requests scan synchronously
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
mounted.lock().unwrap().clear();
|
||||
assert!(watch.scan_now().is_empty());
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
+71
-38
@@ -434,8 +434,11 @@ pub struct ExportOptions {
|
||||
pub limit_kb: Option<u32>,
|
||||
pub sharpen: SharpenFor,
|
||||
pub sharpen_amount: SharpenAmount,
|
||||
/// File name template: `{name}` (original stem), `{seq}` (zero-padded to 3, counting from
|
||||
/// `start_number`), `{date}` (capture date, YYYYMMDD); the extension is appended.
|
||||
/// File name template with the batch-rename tokens ([`crate::rename::expand_tokens`]): `{name}`
|
||||
/// (original stem), `{seq}` (zero-padded to 3, counting from `start_number`; `{seq:N}` for N
|
||||
/// digits), `{date}` (capture date, YYYYMMDD; `{date:%Y-%m-%d}`…), `{num}`, `{folder}`,
|
||||
/// `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; the output
|
||||
/// extension is appended.
|
||||
pub naming: String,
|
||||
/// First `{seq}` value.
|
||||
pub start_number: u32,
|
||||
@@ -550,10 +553,9 @@ impl ExportOptions {
|
||||
r(ResizeMode::ShortEdge, v)
|
||||
} else if let Some(v) = f("megapixels") {
|
||||
r(ResizeMode::Megapixels, v)
|
||||
} else if let Some(v) = f("percent") {
|
||||
r(ResizeMode::Percent, v)
|
||||
} else {
|
||||
return None;
|
||||
let v = f("percent")?;
|
||||
r(ResizeMode::Percent, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -618,36 +620,23 @@ impl ExportOptions {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
}
|
||||
|
||||
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
|
||||
pub fn file_name(&self, stem: &str, seq: usize) -> String {
|
||||
self.file_name_dated(stem, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name`] with the capture time (ISO 8601) for `{date}`.
|
||||
pub fn file_name_dated(&self, stem: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
/// extension is kept for [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
|
||||
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
|
||||
let n = seq + self.start_number.max(1) as usize - 1;
|
||||
let date: String = captured.map(|c| c.chars().take(10).filter(char::is_ascii_digit).collect()).unwrap_or_default();
|
||||
let name = base.replace("{name}", stem).replace("{seq}", &format!("{n:03}")).replace("{date}", &date);
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
|
||||
/// Output file name for photo file `file_name` (the original's extension is kept for
|
||||
/// [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, file_name: &str, seq: usize) -> String {
|
||||
self.file_name_for_dated(file_name, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name_for`] with the capture time for `{date}`.
|
||||
pub fn file_name_for_dated(&self, file_name: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, ""));
|
||||
let name = self.file_name_dated(stem, seq, captured);
|
||||
let name = crate::rename::expand_tokens(base, p, n, 3);
|
||||
let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, ""));
|
||||
if name.trim().is_empty() {
|
||||
// every token came out empty (no title, no capture time…): keep the original name
|
||||
name = stem.to_string();
|
||||
}
|
||||
if self.format == ExportFormat::Original {
|
||||
let base = name.trim_end_matches('.');
|
||||
if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") }
|
||||
} else {
|
||||
name
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,7 +658,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
|
||||
let (w, h) = (img.width, img.height);
|
||||
match &mut img.samples {
|
||||
DeepSamples::U16(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.round().clamp(0.0, 65535.0) as u16;
|
||||
@@ -677,7 +666,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
|
||||
}
|
||||
}
|
||||
DeepSamples::F32(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.max(0.0);
|
||||
@@ -864,7 +853,16 @@ fn parse_shutter(s: &str) -> Option<f64> {
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
|
||||
let m = &photo.meta;
|
||||
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() };
|
||||
// copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL
|
||||
let mut out = Metadata {
|
||||
copyright: text(&m.copyright),
|
||||
copyright_marked: m.copyright_status.marked(),
|
||||
usage_terms: text(&m.usage_terms),
|
||||
copyright_url: text(&m.copyright_url),
|
||||
artist: text(&m.creator),
|
||||
software: Some("LightCraft".into()),
|
||||
..Default::default()
|
||||
};
|
||||
match o.metadata {
|
||||
MetadataPolicy::None => return None,
|
||||
MetadataPolicy::Copyright => return Some(out),
|
||||
@@ -962,7 +960,7 @@ pub fn prepare_export(
|
||||
seq: usize,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref());
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let work = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let meta = export_metadata(p, o);
|
||||
@@ -1180,6 +1178,9 @@ mod tests {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.meta.copyright = "(c) Me".into();
|
||||
p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted;
|
||||
p.meta.usage_terms = "Editorial use only".into();
|
||||
p.meta.copyright_url = "https://example.com/rights".into();
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.gps = Some((43.0, -110.0));
|
||||
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
|
||||
@@ -1191,12 +1192,19 @@ mod tests {
|
||||
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
|
||||
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
|
||||
// "copyright only" keeps all the copyright info: status, usage terms, info URL
|
||||
assert_eq!(
|
||||
(c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()),
|
||||
(Some(true), Some("Editorial use only"), Some("https://example.com/rights"))
|
||||
);
|
||||
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
// embedded and readable back from the JPEG
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
|
||||
assert_eq!(back.copyright.as_deref(), Some("(c) Me"));
|
||||
assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only")));
|
||||
assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights"));
|
||||
assert!(back.gps.is_some());
|
||||
}
|
||||
|
||||
@@ -1249,12 +1257,35 @@ mod tests {
|
||||
assert_eq!(o.format, ExportFormat::Jpeg);
|
||||
assert_eq!(o.quality, 100);
|
||||
assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) }));
|
||||
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.ppi, 240);
|
||||
let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() };
|
||||
assert_eq!(orig.file_name_for("DSC_1.NEF", 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for("noext", 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng");
|
||||
assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng");
|
||||
}
|
||||
|
||||
fn named(file_name: &str) -> lightcraft_catalog::Photo {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Export naming takes the same tokens as Rename Photos and import renaming.
|
||||
#[test]
|
||||
fn naming_uses_the_rename_tokens() {
|
||||
let mut p = named("DSC_0815.NEF");
|
||||
p.captured = Some("2026-09-30T14:05:09".into());
|
||||
p.meta.camera = "Model X/2".into();
|
||||
p.meta.title = "Harbour".into();
|
||||
p.rating = 5;
|
||||
let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() };
|
||||
assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg");
|
||||
assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _");
|
||||
assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits");
|
||||
assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg");
|
||||
// nothing left after expanding: the original name
|
||||
p.meta.title.clear();
|
||||
assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1282,7 +1313,9 @@ mod tests {
|
||||
let full = ExportOptions::default();
|
||||
assert_eq!(ExportOptions::from_json(&full.to_json()), full);
|
||||
assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit");
|
||||
assert_eq!(o.file_name_dated("IMG", 2, Some("2026-09-30T10:00:00")), "20260930-IMG-043.tif");
|
||||
let mut p = named("IMG.png");
|
||||
p.captured = Some("2026-09-30T10:00:00".into());
|
||||
assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
title: m.title.clone().unwrap_or_default(),
|
||||
caption: m.caption.clone().unwrap_or_default(),
|
||||
copyright: m.copyright.clone().unwrap_or_default(),
|
||||
copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked),
|
||||
usage_terms: m.usage_terms.clone().unwrap_or_default(),
|
||||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||||
creator: m.artist.clone().unwrap_or_default(),
|
||||
keywords: m.keywords.clone(),
|
||||
};
|
||||
@@ -114,6 +117,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 +145,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 +175,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
content_hash,
|
||||
embedded_lens: None,
|
||||
xmp: None,
|
||||
preview_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,6 +402,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;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Last-resort panic guard (never-crash standard, `AGENTS.md`): a panic that escapes a command
|
||||
//! becomes that command's error and the session (catalog, library, undo history) stays usable.
|
||||
//! It's a safety net, not a way to report errors: commands return `Err` themselves.
|
||||
|
||||
use std::any::Any;
|
||||
use std::io::Write;
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The text of a panic payload (`panic!("…")` gives a `&str` or a `String`).
|
||||
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
|
||||
match (payload.downcast_ref::<&str>(), payload.downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown panic".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`; a panic inside it becomes `Err("<what> failed unexpectedly: <message>")`. On wasm32
|
||||
/// (built with `panic = "abort"`) nothing can be caught and this just runs `f`.
|
||||
pub fn catch<T>(what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
|
||||
let msg = format!("{what} failed unexpectedly: {}", panic_message(p.as_ref()));
|
||||
log::error!("{msg}");
|
||||
msg
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a panic hook that keeps the default report (stderr) and also appends the panic's
|
||||
/// location and message to `log` (best effort), so a panic in a GUI session leaves a trace.
|
||||
pub fn install_hook(log: PathBuf) {
|
||||
let default = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
default(info);
|
||||
let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
|
||||
let _ = writeln!(f, "{secs} [{thread}] panic at {at}: {}", panic_message(info.payload()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_an_error() {
|
||||
assert_eq!(catch("ok", || 7), Ok(7));
|
||||
let e = catch("`demo.cmd`", || -> u32 { panic!("boom {}", 1) }).unwrap_err();
|
||||
assert_eq!(e, "`demo.cmd` failed unexpectedly: boom 1");
|
||||
assert!(catch("x", || std::panic::panic_any(5u8)).unwrap_err().ends_with("unknown panic"));
|
||||
}
|
||||
}
|
||||
+286
-81
@@ -6,12 +6,16 @@
|
||||
//! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel
|
||||
//! on native targets.
|
||||
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
|
||||
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
|
||||
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]).
|
||||
//! 5. *Add* in place (the photo points at the original file), *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy, or *move*
|
||||
//! there: placed like a copy (with its XMP sidecars), and the original removed only once its
|
||||
//! catalog record is committed and saved (see [`crate::import_move`] for the safety rules).
|
||||
//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills
|
||||
//! in when the file has none (read before step 5, so it also files and names the copy).
|
||||
//! 7. Optionally apply a preset and add keywords (the import dialog's options).
|
||||
//! 8. Commit all new photos as one undoable op.
|
||||
//! 8. Commit all new photos as one undoable op. (Undoing a move removes the catalog records;
|
||||
//! the files stay at the destination — undo never moves files back over a card.)
|
||||
//!
|
||||
//! The import dialog first calls [`scan`] (`library.importPreview`): the same expansion, probing
|
||||
//! and duplicate detection without adding anything, so the user can review the candidates. The
|
||||
@@ -39,6 +43,9 @@ pub enum ImportMode {
|
||||
Add,
|
||||
/// Copy the files into the library's `Originals/` folder.
|
||||
Copy,
|
||||
/// Move the files there: as Copy, then each original (and its sidecars) is removed from the
|
||||
/// source once the copy is verified and catalogued.
|
||||
Move,
|
||||
}
|
||||
|
||||
/// What an import does besides adding the photos.
|
||||
@@ -66,8 +73,9 @@ pub struct ImportOptions {
|
||||
pub convert_dng: bool,
|
||||
}
|
||||
|
||||
/// How copies are filed in the destination.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
/// How copies are filed in the destination. The date is the capture time, else the time of the
|
||||
/// import.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Organize {
|
||||
/// `YYYY/YYYY-MM-DD/` by capture date.
|
||||
#[default]
|
||||
@@ -76,17 +84,36 @@ pub enum Organize {
|
||||
ByMonth,
|
||||
/// All in the destination itself.
|
||||
Flat,
|
||||
/// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own
|
||||
/// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`];
|
||||
/// never outside the destination).
|
||||
Template(String),
|
||||
}
|
||||
|
||||
impl Organize {
|
||||
/// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`).
|
||||
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),
|
||||
t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders (inside the destination) a copy of `p` goes to.
|
||||
pub fn folders(&self, p: &Photo) -> Vec<String> {
|
||||
let date = p.date();
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
match self {
|
||||
Organize::ByDay => vec![year.into(), day.into()],
|
||||
Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()],
|
||||
Organize::Flat => Vec::new(),
|
||||
Organize::Template(t) => crate::rename::expand_folder(t, p, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file found by [`scan`], for the import review.
|
||||
@@ -108,6 +135,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)]
|
||||
@@ -123,6 +153,13 @@ pub struct Duplicate {
|
||||
pub struct ImportReport {
|
||||
pub imported: Vec<u64>,
|
||||
pub duplicates: Vec<Duplicate>,
|
||||
/// Move: the originals that were moved (and removed from the source).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub moved: Vec<Moved>,
|
||||
/// Move: originals (or sidecars) left at the source, and why — the photo may still have been
|
||||
/// imported (from its copy, or in place).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub kept: Vec<Kept>,
|
||||
/// (path, error)
|
||||
pub failed: Vec<(String, String)>,
|
||||
/// Files found after expanding folders.
|
||||
@@ -131,6 +168,23 @@ pub struct ImportReport {
|
||||
pub sidecars: usize,
|
||||
}
|
||||
|
||||
/// A file moved by an import.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Moved {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Its sidecars' new paths.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub sidecars: Vec<String>,
|
||||
}
|
||||
|
||||
/// A source a Move left where it was.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Kept {
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
|
||||
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
|
||||
/// camera intended); a user default preset ([`ImportDefaults`]) goes on top.
|
||||
@@ -211,7 +265,8 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) {
|
||||
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) => {
|
||||
@@ -261,11 +316,12 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
|
||||
|
||||
/// 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, paths)
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
|
||||
}
|
||||
|
||||
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
let Some(probe) = s.media.file_probe.clone() else {
|
||||
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| {
|
||||
@@ -291,7 +347,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);
|
||||
}
|
||||
});
|
||||
@@ -300,19 +360,23 @@ 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()
|
||||
}
|
||||
|
||||
/// 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 = 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(),
|
||||
};
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
@@ -331,57 +395,112 @@ fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Optio
|
||||
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) => {
|
||||
@@ -393,14 +512,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.
|
||||
@@ -413,7 +532,11 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
let mode = opts.mode;
|
||||
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
|
||||
// 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 transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
|
||||
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
if mode == ImportMode::Move && opts.local {
|
||||
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
@@ -422,8 +545,8 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
.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()));
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving 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());
|
||||
@@ -454,10 +577,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
}
|
||||
|
||||
let probed = probe_all(s, &todo);
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).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.media.file_probe.as_ref(), &missing, &ScanProgress::default()).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 now = (s.clock)();
|
||||
let mut ops = promote;
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
let info = match info {
|
||||
Ok(i) => i,
|
||||
@@ -474,28 +604,72 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == 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));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut info = info;
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
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)) =>
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
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 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));
|
||||
continue;
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed.push(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
@@ -504,7 +678,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
// 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) => {
|
||||
@@ -525,20 +698,11 @@ 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).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;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("import {path}: XMP: {e}"),
|
||||
}
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
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);
|
||||
@@ -560,12 +724,52 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
p.local = opts.local;
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })?;
|
||||
let log0 = s.pending_log.len();
|
||||
if !ops.is_empty()
|
||||
&& let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
{
|
||||
// nothing was catalogued: take the moves back (their sources were never touched)
|
||||
placed.iter().for_each(crate::import_move::rollback);
|
||||
return Err(e);
|
||||
}
|
||||
if !placed.is_empty() {
|
||||
finish_moves(s, placed, log0, &mut report);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Move, after the commit: save the library, then remove each source whose destination is
|
||||
/// still intact. Nothing is removed when the library can't be saved.
|
||||
fn finish_moves(s: &mut Session, placed: Vec<crate::import_move::Placed>, log0: usize, report: &mut ImportReport) {
|
||||
// (saving here clears the pending ops, so the sidecars `execute` would auto-write are written here)
|
||||
let auto: Vec<Op> = if s.xmp.auto_write { s.pending_log.get(log0..).map(<[Op]>::to_vec).unwrap_or_default() } else { Vec::new() };
|
||||
let before = s.pending_log.len();
|
||||
if let Err(e) = s.persist() {
|
||||
for p in placed {
|
||||
let reason = format!("the library could not be saved ({e}): the original stays; the photo uses the copy at {}", p.dst.display());
|
||||
report.kept.push(Kept { path: p.src.to_string_lossy().to_string(), reason });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for p in placed {
|
||||
let from = p.src.to_string_lossy().to_string();
|
||||
match crate::import_move::finish(&p) {
|
||||
Ok(kept) => {
|
||||
let sidecars = p.sidecars.iter().map(|sc| sc.dst.to_string_lossy().to_string()).collect();
|
||||
report.moved.push(Moved { from, to: p.dst.to_string_lossy().to_string(), sidecars });
|
||||
report.kept.extend(kept.into_iter().map(|(path, reason)| Kept { path, reason }));
|
||||
}
|
||||
Err(reason) => {
|
||||
log::warn!("import: move {from}: {reason}");
|
||||
report.kept.push(Kept { path: from, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.pending_log.len() < before && s.interaction.is_none() && !auto.is_empty() {
|
||||
s.auto_write_sidecars(&auto);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock
|
||||
/// offset); for [`Session::clock`] on native hosts.
|
||||
pub fn system_clock() -> String {
|
||||
@@ -598,6 +802,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());
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Import → **Move**: the file transfer behind [`crate::import::ImportMode::Move`].
|
||||
//!
|
||||
//! Moving deletes the user's originals, so it is done in two phases and every step that could
|
||||
//! lose data comes last:
|
||||
//!
|
||||
//! 1. [`place`] puts the photo (and its XMP sidecars) at the destination **without touching the
|
||||
//! source**: a hard link when source and destination share a volume (instant, no extra space,
|
||||
//! never overwrites), else a copy that is written to a new file (never overwriting), synced to
|
||||
//! disk and compared byte for byte with the original. A name that is taken gets -1, -2…
|
||||
//! Anything that fails is undone ([`rollback`]): the partial destination is removed and the
|
||||
//! source stays as it was.
|
||||
//! 2. Only after the catalog records pointing at the destinations were committed and saved
|
||||
//! does [`finish`] remove each source — after checking once more that its destination is
|
||||
//! still there with the same size. A source that can't be removed (a read-only card, a
|
||||
//! locked file) stays and is reported; the photo is then imported from the copy.
|
||||
//!
|
||||
//! A `IMG_1.xmp` sidecar is shared by `IMG_1.CR3` and `IMG_1.JPG`: it is copied along with each,
|
||||
//! and removed from the source only once no photo with that name is left beside it.
|
||||
//!
|
||||
//! (Not `std::fs::rename`: it would remove the source before the catalog knows the new place,
|
||||
//! and on Unix it silently replaces an existing destination.)
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A photo put in place by [`place`]; its source is untouched.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Placed {
|
||||
pub src: PathBuf,
|
||||
pub dst: PathBuf,
|
||||
pub sidecars: Vec<PlacedSidecar>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PlacedSidecar {
|
||||
pub src: PathBuf,
|
||||
pub dst: PathBuf,
|
||||
/// `false`: an identical sidecar was already there (the other half of a raw + JPEG pair).
|
||||
pub created: bool,
|
||||
/// Named after the photo's stem (`IMG_1.xmp`), so possibly shared with a sibling photo.
|
||||
pub stem: bool,
|
||||
}
|
||||
|
||||
/// Test-only fault injection (per thread), to exercise the failure paths.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum Fault {
|
||||
#[default]
|
||||
None,
|
||||
/// Hard links fail, as across volumes: the copy path runs.
|
||||
NoLink,
|
||||
/// Writing the copy fails half-way (implies `NoLink`).
|
||||
FailWrite,
|
||||
/// The copy comes out different from the original, caught by the verification (implies `NoLink`).
|
||||
Corrupt,
|
||||
/// Removing the source fails (as on a read-only card).
|
||||
FailRemove,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static FAULT: std::cell::Cell<Fault> = const { std::cell::Cell::new(Fault::None) };
|
||||
}
|
||||
|
||||
/// Inject a fault for the calling thread's next moves (tests only).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn inject(f: Fault) {
|
||||
FAULT.with(|c| c.set(f));
|
||||
}
|
||||
|
||||
fn injected(f: Fault) -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
FAULT.with(|c| c.get()) == f
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = f;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` inside `dir` (as written, or once symlinks and `..` are resolved)?
|
||||
pub(crate) fn inside(path: &Path, dir: &Path) -> bool {
|
||||
if path.starts_with(dir) {
|
||||
return true;
|
||||
}
|
||||
match (fs::canonicalize(path), fs::canonicalize(dir)) {
|
||||
(Ok(p), Ok(d)) => p.starts_with(d),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_symlink(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
|
||||
}
|
||||
|
||||
/// The XMP sidecars beside `src`, both naming conventions (`IMG_1.xmp`, `IMG_1.CR3.xmp`; the
|
||||
/// `.xmp` extension in any case): (path, named after the stem).
|
||||
pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> {
|
||||
let (Some(dir), Some(name)) = (src.parent(), src.file_name().map(|n| n.to_string_lossy().to_string())) else { return Vec::new() };
|
||||
let stem = src.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut out: Vec<(PathBuf, bool)> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let n = e.file_name().to_string_lossy().to_string();
|
||||
let (base, ext) = n.rsplit_once('.')?;
|
||||
if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() {
|
||||
return None;
|
||||
}
|
||||
if base == name {
|
||||
Some((e.path(), false))
|
||||
} else if base == stem {
|
||||
Some((e.path(), true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `src` into `dir` as `name` (-1, -2… when taken) with its sidecars, leaving the source
|
||||
/// untouched. On failure nothing is left at the destination.
|
||||
pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String> {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (name.to_string(), String::new()),
|
||||
};
|
||||
let mut dst = None;
|
||||
for i in 0..100_000u32 {
|
||||
let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) };
|
||||
match link_or_copy(src, &d) {
|
||||
Ok(()) => {
|
||||
dst = Some(d);
|
||||
break;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(format!("move {}: {e} (the original is kept)", src.display())),
|
||||
}
|
||||
}
|
||||
let Some(dst) = dst else { return Err(format!("move {}: no free name in {}", src.display(), dir.display())) };
|
||||
let mut placed = Placed { src: src.to_path_buf(), dst, sidecars: Vec::new() };
|
||||
for (sc, stem_named) in sidecars_of(src) {
|
||||
let sc_ext = sc.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "xmp".into());
|
||||
let sc_dst =
|
||||
if stem_named { placed.dst.with_extension(&sc_ext) } else { PathBuf::from(format!("{}.{sc_ext}", placed.dst.to_string_lossy())) };
|
||||
let r = if sc_dst.exists() {
|
||||
match same_bytes(&sc, &sc_dst) {
|
||||
Ok(true) => Ok(false),
|
||||
Ok(false) => Err(format!("a different sidecar is already at {} (the original is kept)", sc_dst.display())),
|
||||
Err(e) => Err(format!("{}: {e} (the original is kept)", sc_dst.display())),
|
||||
}
|
||||
} else {
|
||||
link_or_copy(&sc, &sc_dst).map(|()| true).map_err(|e| format!("move {}: {e} (the original is kept)", sc.display()))
|
||||
};
|
||||
match r {
|
||||
Ok(created) => placed.sidecars.push(PlacedSidecar { src: sc, dst: sc_dst, created, stem: stem_named }),
|
||||
Err(e) => {
|
||||
rollback(&placed);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Undo a [`place`]: remove what it created at the destination (the sources were never touched).
|
||||
pub(crate) fn rollback(p: &Placed) {
|
||||
for sc in p.sidecars.iter().filter(|s| s.created) {
|
||||
let _ = fs::remove_file(&sc.dst);
|
||||
}
|
||||
let _ = fs::remove_file(&p.dst);
|
||||
}
|
||||
|
||||
/// Remove a placed photo's source (its catalog record is committed and saved): only when the
|
||||
/// destination is still there with the same size. Then its sidecars, unless another photo still
|
||||
/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why).
|
||||
pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
|
||||
let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) {
|
||||
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(),
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
return Err(format!("{} is missing or changed: the original is kept", p.dst.display()));
|
||||
}
|
||||
if injected(Fault::FailRemove) {
|
||||
return Err("could not remove the original (injected): it stays where it was".into());
|
||||
}
|
||||
fs::remove_file(&p.src).map_err(|e| format!("could not remove the original ({e}): it stays where it was"))?;
|
||||
let mut kept = Vec::new();
|
||||
for sc in &p.sidecars {
|
||||
let path = sc.src.to_string_lossy().to_string();
|
||||
if !same_bytes(&sc.src, &sc.dst).unwrap_or(false) {
|
||||
kept.push((path, format!("{} is missing or changed: the sidecar is kept", sc.dst.display())));
|
||||
continue;
|
||||
}
|
||||
if sc.stem
|
||||
&& let Some(other) = sibling_photo(&sc.src)
|
||||
{
|
||||
kept.push((path, format!("also the sidecar of {other}: kept beside it")));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = fs::remove_file(&sc.src) {
|
||||
kept.push((path, format!("could not remove the sidecar ({e})")));
|
||||
}
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// A photo (by extension) still beside a stem-named sidecar, sharing its stem.
|
||||
fn sibling_photo(sidecar: &Path) -> Option<String> {
|
||||
let dir = sidecar.parent()?;
|
||||
let stem = sidecar.file_stem()?.to_string_lossy().to_string();
|
||||
fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find_map(|f| {
|
||||
let ok = f.file_stem().is_some_and(|s| s.to_string_lossy() == stem) && crate::import::is_supported(&f);
|
||||
ok.then(|| f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
|
||||
})
|
||||
}
|
||||
|
||||
/// `dst` = `src` without touching `src`: a hard link, else a verified copy. Never overwrites
|
||||
/// (`AlreadyExists` when `dst` is taken).
|
||||
fn link_or_copy(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
let force_copy = injected(Fault::NoLink) || injected(Fault::FailWrite) || injected(Fault::Corrupt);
|
||||
if !force_copy && !is_symlink(src) {
|
||||
match fs::hard_link(src, dst) {
|
||||
Ok(()) => {
|
||||
let (a, b) = (fs::metadata(src)?, fs::metadata(dst)?);
|
||||
if a.len() == b.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = fs::remove_file(dst);
|
||||
return Err(io::Error::other("the link doesn't match the original"));
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(e),
|
||||
// another volume, or no hard links there (FAT/exFAT cards, some shares): copy
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
copy_verified(src, dst)
|
||||
}
|
||||
|
||||
/// Copy into a new file, sync it to disk and compare it with the original; on any failure the
|
||||
/// partial copy is removed.
|
||||
fn copy_verified(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
let mut out = OpenOptions::new().write(true).create_new(true).open(dst)?;
|
||||
let r = (|| {
|
||||
let mut inp = File::open(src)?;
|
||||
if injected(Fault::FailWrite) {
|
||||
out.write_all(b"partial")?;
|
||||
return Err(io::Error::other("write failed (injected)"));
|
||||
}
|
||||
io::copy(&mut inp, &mut out)?;
|
||||
if injected(Fault::Corrupt) {
|
||||
out.write_all(b"!")?;
|
||||
}
|
||||
out.sync_all()?;
|
||||
if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
|
||||
let _ = out.set_modified(m);
|
||||
}
|
||||
if !same_bytes(src, dst)? {
|
||||
return Err(io::Error::other("the copy differs from the original"));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
drop(out);
|
||||
if r.is_err() {
|
||||
let _ = fs::remove_file(dst);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Do two files hold the same bytes?
|
||||
pub(crate) fn same_bytes(a: &Path, b: &Path) -> io::Result<bool> {
|
||||
if fs::metadata(a)?.len() != fs::metadata(b)?.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
let (mut fa, mut fb) = (File::open(a)?, File::open(b)?);
|
||||
let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);
|
||||
loop {
|
||||
let (na, nb) = (fill(&mut fa, &mut ba)?, fill(&mut fb, &mut bb)?);
|
||||
if na != nb || ba.get(..na) != bb.get(..nb) {
|
||||
return Ok(false);
|
||||
}
|
||||
if na == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read until `buf` is full or the file ends.
|
||||
fn fill(f: &mut File, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let mut n = 0;
|
||||
while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
|
||||
match f.read(rest) {
|
||||
Ok(0) => break,
|
||||
Ok(k) => n += k,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
+66
-11
@@ -17,12 +17,15 @@ pub mod demo;
|
||||
pub mod devices;
|
||||
pub mod export;
|
||||
pub mod files;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
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;
|
||||
@@ -36,7 +39,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use media::{RenderJob, SourceLevel};
|
||||
use serde_json::Value;
|
||||
pub use view::{Browse, LibrarySource, Selection};
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips};
|
||||
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -70,6 +73,9 @@ pub struct Interaction {
|
||||
pub original: Arc<DevelopSettings>,
|
||||
}
|
||||
|
||||
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
|
||||
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub struct Session {
|
||||
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
|
||||
/// that changed, nothing else).
|
||||
@@ -80,8 +86,14 @@ pub struct Session {
|
||||
pub sort: Sort,
|
||||
pub selection: Selection,
|
||||
/// The grid order for the current source/filter/sort (cached by catalog revision).
|
||||
visible: Vec<PhotoId>,
|
||||
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
|
||||
visible: Arc<[PhotoId]>,
|
||||
visible_key: Option<(u64, String)>,
|
||||
/// Identifies the current `visible` list: a new value (unique in the process) every time it
|
||||
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
|
||||
visible_gen: u64,
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
pub undo: Vec<UndoEntry>,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -124,6 +136,9 @@ pub struct Session {
|
||||
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).
|
||||
@@ -151,6 +166,8 @@ pub struct Session {
|
||||
pub import_defaults: import::ImportDefaults,
|
||||
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
|
||||
pub cache_mb: u32,
|
||||
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
|
||||
pub smart_previews_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -168,8 +185,10 @@ impl Session {
|
||||
filter: Filter::default(),
|
||||
sort: Sort::default(),
|
||||
selection: Selection::default(),
|
||||
visible: Vec::new(),
|
||||
visible: Vec::new().into(),
|
||||
visible_key: None,
|
||||
visible_gen: 0,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
@@ -194,6 +213,7 @@ impl Session {
|
||||
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(),
|
||||
@@ -207,6 +227,7 @@ impl Session {
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +247,8 @@ impl Session {
|
||||
let log_start = self.pending_log.len();
|
||||
let was_active = self.active();
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
// last-resort guard: a panic in a command is that command's error, not a crash
|
||||
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
self.depth -= 1;
|
||||
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
|
||||
self.previous_active = was_active;
|
||||
@@ -473,7 +495,7 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
self.visible = self.catalog.query(&f, &self.sort);
|
||||
let mut visible = self.catalog.query(&f, &self.sort);
|
||||
if matches!(self.source, LibrarySource::Album(_))
|
||||
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
|
||||
&& let LibrarySource::Album(a) = self.source
|
||||
@@ -482,35 +504,66 @@ impl Session {
|
||||
&& self.filter == Filter::default()
|
||||
{
|
||||
let order = al.photos.clone();
|
||||
self.visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
||||
if !self.sort.ascending {
|
||||
self.visible.reverse();
|
||||
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| {
|
||||
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));
|
||||
// only the photos the query kept (library photos, not Local browse records) are checked
|
||||
let cat = &self.catalog;
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
self.visible = self.catalog.arrange_stacks(&self.visible);
|
||||
visible = self.catalog.arrange_stacks(&visible);
|
||||
}
|
||||
self.visible = visible.into();
|
||||
self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.visible_key = Some(key);
|
||||
}
|
||||
&self.visible
|
||||
}
|
||||
|
||||
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
|
||||
/// them; `None` where that is not a separate number (Missing Photos).
|
||||
pub fn source_total(&mut self) -> Option<usize> {
|
||||
if self.source == LibrarySource::Missing {
|
||||
return None;
|
||||
}
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
|
||||
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
|
||||
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
let b = self.browse.clone().unwrap_or_default();
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
let n = self.catalog.query(&f, &self.sort).len();
|
||||
self.total = Some((key, n));
|
||||
}
|
||||
self.total.as_ref().map(|t| t.1)
|
||||
}
|
||||
|
||||
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
|
||||
self.visible().to_vec()
|
||||
}
|
||||
|
||||
/// The visible photos without copying them, with their generation: equal generations mean
|
||||
/// the very same list (same catalog revision, source, filter, sort), so a frontend can cache
|
||||
/// whatever it derives from the list under that number.
|
||||
pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) {
|
||||
self.visible();
|
||||
(self.visible_gen, self.visible.clone())
|
||||
}
|
||||
|
||||
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
|
||||
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
@@ -548,6 +601,8 @@ mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
#[cfg(test)]
|
||||
mod tests_libops;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//!
|
||||
//! Every top-level [`Session::execute`] persists the ops it produced (fsynced) before returning,
|
||||
//! so a crash loses at most the command in flight. The log is compacted into a snapshot when it
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]) and on [`Session::close_library`].
|
||||
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]; written by a worker thread on native, see
|
||||
//! [`lightcraft_catalog::journal`]) and on [`Session::close_library`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -91,6 +92,10 @@ impl Library {
|
||||
pub fn journal(&self) -> &Journal {
|
||||
&self.journal
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn journal_mut(&mut self) -> &mut Journal {
|
||||
&mut self.journal
|
||||
}
|
||||
pub fn thumbs_dir(&self) -> PathBuf {
|
||||
self.dir.join("thumbs")
|
||||
}
|
||||
@@ -112,6 +117,8 @@ struct PrefsFile {
|
||||
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.
|
||||
@@ -124,6 +131,8 @@ struct PrefsFile {
|
||||
import: crate::import::ImportDefaults,
|
||||
/// Thumbnail disk cache budget (MB, 0 = default).
|
||||
cache_mb: u32,
|
||||
/// Folder for smart previews (default: `Smart Previews` in the library).
|
||||
smart_previews_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -137,6 +146,15 @@ fn presets_json(s: &Session) -> String {
|
||||
serde_json::to_string_pretty(&f).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Finish a background compaction that is done (cheap otherwise). A failed one lost nothing (the
|
||||
/// log is kept whole) and is retried later; it is reported like other persistence errors.
|
||||
fn poll_compaction(lib: &mut Library) {
|
||||
if let Err(e) = lib.journal.poll() {
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str) -> Option<T> {
|
||||
store.read(name).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
|
||||
}
|
||||
@@ -156,6 +174,12 @@ impl Session {
|
||||
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));
|
||||
// the current library may be these same files: let its background snapshot land first
|
||||
if let Some(old) = self.library.as_mut()
|
||||
&& let Err(e) = old.journal.wait()
|
||||
{
|
||||
log::error!("library: {e}");
|
||||
}
|
||||
let (mut journal, catalog, report) = Journal::open(catalog)?;
|
||||
self.catalog = catalog;
|
||||
self.undo.clear();
|
||||
@@ -189,6 +213,7 @@ impl Session {
|
||||
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);
|
||||
@@ -197,6 +222,10 @@ impl Session {
|
||||
self.recent_keywords = prefs.recent_keywords;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
|
||||
if let Some(d) = &self.smart_previews_dir {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
@@ -219,8 +248,8 @@ impl Session {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&self.library.as_ref().expect("just set").report)
|
||||
let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&library.report)
|
||||
}
|
||||
|
||||
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||
@@ -236,9 +265,11 @@ impl Session {
|
||||
self.pending_log.clear();
|
||||
lib.last_error = None;
|
||||
}
|
||||
poll_compaction(lib);
|
||||
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
|
||||
// The snapshot is written by a worker thread (natively); appends go on meanwhile.
|
||||
if lib.journal.wants_snapshot() && self.interaction.is_none() {
|
||||
lib.journal.snapshot(&self.catalog)?;
|
||||
lib.journal.snapshot_in_background(&self.catalog)?;
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
@@ -257,6 +288,8 @@ impl Session {
|
||||
pub fn persist_if_dirty(&mut self) {
|
||||
if self.library.is_some() && !self.pending_log.is_empty() {
|
||||
let _ = self.persist();
|
||||
} else if let Some(lib) = self.library.as_mut() {
|
||||
poll_compaction(lib);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +342,7 @@ impl Session {
|
||||
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(),
|
||||
@@ -316,6 +350,7 @@ impl Session {
|
||||
recent_keywords: self.recent_keywords.clone(),
|
||||
import: self.import_defaults.clone(),
|
||||
cache_mb: self.cache_mb,
|
||||
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
|
||||
@@ -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];
|
||||
@@ -523,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 {
|
||||
@@ -751,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>;
|
||||
|
||||
@@ -309,14 +309,10 @@ fn output_path(first: &str, suffix: &str) -> String {
|
||||
let p = std::path::Path::new(first);
|
||||
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
|
||||
for k in 1.. {
|
||||
let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") };
|
||||
let cand = dir.join(name);
|
||||
if !cand.exists() {
|
||||
return cand.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") });
|
||||
// (`u64::MAX` taken names can't exist; the last candidate is the fallback)
|
||||
let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX));
|
||||
free.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Parse `merge.*` command parameters into a kind and finishing options.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 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`].
|
||||
//! 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.
|
||||
@@ -23,7 +24,7 @@ pub struct Imported {
|
||||
}
|
||||
|
||||
/// Extensions of files read as presets (beyond our own `.lcpreset`).
|
||||
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng"];
|
||||
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"];
|
||||
|
||||
// ------------------------------------------------------------------------------------- zip
|
||||
|
||||
@@ -426,15 +427,24 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
"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()))
|
||||
};
|
||||
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p)).map(str::to_string)
|
||||
// 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)
|
||||
}
|
||||
|
||||
fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
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
|
||||
@@ -486,6 +496,8 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<V
|
||||
}
|
||||
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}'))?;
|
||||
@@ -522,7 +534,7 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<V
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -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 || ["xmp", "lrtemplate", "zip"].contains(&ext.as_str()) {
|
||||
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) {
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
+258
-13
@@ -1,10 +1,13 @@
|
||||
//! Batch rename: a file-name template applied to the selected photos, renaming the files on
|
||||
//! disk (with their XMP sidecars) safely.
|
||||
//!
|
||||
//! Template tokens: `{name}` (current name without extension), `{seq}` / `{seq:3}` (sequence number,
|
||||
//! zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date; `%Y %y %m %d %H %M %S`),
|
||||
//! `{camera}`, `{title}`, `{ext}` (extension, without the dot). Characters that aren't allowed in
|
||||
//! file names become `-`. The original extension is always kept.
|
||||
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
|
||||
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
|
||||
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
|
||||
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
|
||||
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
|
||||
//! typed. Characters that aren't allowed in file names become `-`. The original extension is always
|
||||
//! kept.
|
||||
//!
|
||||
//! Safety:
|
||||
//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`…
|
||||
@@ -79,8 +82,165 @@ fn format_date(iso: &str, fmt: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and
|
||||
/// command description shows; a test checks that [`expand_tokens`] knows each of them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct TokenHelp {
|
||||
/// The token as typed, e.g. `{seq:3}`.
|
||||
pub tag: &'static str,
|
||||
/// Other spellings with the same meaning.
|
||||
pub aliases: &'static [&'static str],
|
||||
pub meaning: &'static str,
|
||||
}
|
||||
|
||||
/// Every template token, in the order the help shows them.
|
||||
pub const TOKENS: &[TokenHelp] = &[
|
||||
TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" },
|
||||
TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" },
|
||||
TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" },
|
||||
TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" },
|
||||
TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" },
|
||||
TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" },
|
||||
TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" },
|
||||
TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" },
|
||||
TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" },
|
||||
TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" },
|
||||
TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" },
|
||||
TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" },
|
||||
TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" },
|
||||
TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" },
|
||||
];
|
||||
|
||||
/// The `%` directives of `{date:…}`.
|
||||
pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
|
||||
("%Y", "year, 4 digits"),
|
||||
("%y", "year, 2 digits"),
|
||||
("%m", "month 01–12"),
|
||||
("%d", "day 01–31"),
|
||||
("%H", "hour 00–23"),
|
||||
("%M", "minute"),
|
||||
("%S", "second"),
|
||||
("%%", "a literal %"),
|
||||
];
|
||||
|
||||
/// How templates behave, one sentence each (shown under the token list).
|
||||
pub const TEMPLATE_NOTES: &[&str] = &[
|
||||
"The original extension is always added; {ext} only puts it inside the name as well.",
|
||||
"A blank template keeps the original names.",
|
||||
"{date} is the capture time; a photo without one uses the time it was imported.",
|
||||
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.",
|
||||
"Unknown tags stay as typed — check the preview for a {typo}.",
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….",
|
||||
];
|
||||
|
||||
/// The tokens as one line (`{name} {num} {seq} …`), for compact hints.
|
||||
pub fn token_summary() -> String {
|
||||
TOKENS.iter().map(|t| t.tag).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48).
|
||||
pub fn sample_photo() -> Photo {
|
||||
let mut p =
|
||||
Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00");
|
||||
p.captured = Some("2026-01-14T05:58:48".into());
|
||||
p.rating = 4;
|
||||
p.meta.camera = "Canon EOS R5".into();
|
||||
p.meta.lens = "RF24-70mm F2.8".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.meta.title = "Harbour".into();
|
||||
p.meta.creator = "Ann Lee".into();
|
||||
p
|
||||
}
|
||||
|
||||
/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column.
|
||||
pub fn token_example(tag: &str) -> String {
|
||||
expand_tokens(tag, &sample_photo(), 1, 1)
|
||||
}
|
||||
|
||||
/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be
|
||||
/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels.
|
||||
pub fn folder_template_error(template: &str) -> Option<String> {
|
||||
let t = template.trim();
|
||||
if t.is_empty() {
|
||||
return Some("the folder template is empty".into());
|
||||
}
|
||||
let b = t.as_bytes();
|
||||
if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') {
|
||||
return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into());
|
||||
}
|
||||
if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) {
|
||||
return Some("the folder template may not contain . or .. folders".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The folders (relative to the destination) a folder template gives `p`: the template's own `/`
|
||||
/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder
|
||||
/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and
|
||||
/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty
|
||||
/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s
|
||||
/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped.
|
||||
pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec<String> {
|
||||
template
|
||||
.split(['/', '\\'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && !matches!(*s, "." | ".."))
|
||||
.map(|s| {
|
||||
let v = sanitize(&expand_tokens(s, p, seq, 1));
|
||||
if v.is_empty() { "unknown".to_string() } else { v }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
|
||||
pub fn unknown_tokens(template: &str) -> Vec<String> {
|
||||
let p = sample_photo();
|
||||
let mut out = Vec::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let Some(j) = rest[i..].find('}') else { break };
|
||||
let tag = &rest[i..i + j + 1];
|
||||
if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) {
|
||||
out.push(tag.to_string());
|
||||
}
|
||||
rest = &rest[i + j + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every token with its meaning and example, the date directives and the notes, as JSON (the
|
||||
/// `photo.renameTokens` command).
|
||||
pub fn token_help_json() -> serde_json::Value {
|
||||
let tokens: Vec<serde_json::Value> = TOKENS
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)}))
|
||||
.collect();
|
||||
let directives: Vec<serde_json::Value> = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect();
|
||||
serde_json::json!({
|
||||
"tokens": tokens,
|
||||
"dateDirectives": directives,
|
||||
"notes": TEMPLATE_NOTES,
|
||||
"sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48",
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
|
||||
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
let out = expand_tokens(template, p, seq, 1);
|
||||
let mut stem_out = sanitize(&out);
|
||||
if stem_out.is_empty() {
|
||||
stem_out = sanitize(stem);
|
||||
}
|
||||
if stem_out.is_empty() {
|
||||
stem_out = "photo".into();
|
||||
}
|
||||
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
|
||||
}
|
||||
|
||||
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
|
||||
/// is zero-padded to `seq_width` digits.
|
||||
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
@@ -95,13 +255,27 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
|
||||
let v = match name.trim().to_ascii_lowercase().as_str() {
|
||||
"name" | "filename" => stem.to_string(),
|
||||
"num" => {
|
||||
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
|
||||
stem[stem.len() - digits..].to_string()
|
||||
}
|
||||
"seq" | "n" => {
|
||||
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(1).min(9);
|
||||
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
|
||||
format!("{seq:0w$}")
|
||||
}
|
||||
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
|
||||
"folder" => match &p.source {
|
||||
Source::File { path } => {
|
||||
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
|
||||
}
|
||||
Source::Demo { .. } => String::new(),
|
||||
},
|
||||
"camera" => p.meta.camera.clone(),
|
||||
"lens" => p.meta.lens.clone(),
|
||||
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
|
||||
"rating" => p.rating.to_string(),
|
||||
"title" => p.meta.title.clone(),
|
||||
"creator" => p.meta.creator.clone(),
|
||||
"ext" => ext.to_string(),
|
||||
_ => format!("{{{tok}}}"),
|
||||
};
|
||||
@@ -109,14 +283,7 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
rest = &rest[i + j + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
let mut stem_out = sanitize(&out);
|
||||
if stem_out.is_empty() {
|
||||
stem_out = sanitize(stem);
|
||||
}
|
||||
if stem_out.is_empty() {
|
||||
stem_out = "photo".into();
|
||||
}
|
||||
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
|
||||
out
|
||||
}
|
||||
|
||||
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
|
||||
@@ -333,6 +500,66 @@ impl Session {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_templates_stay_inside_the_destination() {
|
||||
let mut p = sample_photo();
|
||||
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
|
||||
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
|
||||
// metadata can't add levels or climb out
|
||||
p.meta.camera = "../../etc/x".into();
|
||||
p.meta.title = "..".into();
|
||||
assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]);
|
||||
p.meta.camera = "C:\\Windows".into();
|
||||
assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]);
|
||||
// missing metadata → `unknown`; missing capture time → the import time
|
||||
p.meta.camera.clear();
|
||||
p.captured = None;
|
||||
assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]);
|
||||
for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] {
|
||||
assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected");
|
||||
}
|
||||
for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] {
|
||||
assert_eq!(folder_template_error(ok), None, "{ok:?}");
|
||||
}
|
||||
// even unchecked, rejected levels are skipped rather than followed
|
||||
assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]);
|
||||
}
|
||||
|
||||
/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is
|
||||
/// replaced, never left literal, and the help's examples match the documented behaviour.
|
||||
#[test]
|
||||
fn token_help_matches_the_implementation() {
|
||||
let p = sample_photo();
|
||||
for t in TOKENS {
|
||||
for tag in std::iter::once(&t.tag).chain(t.aliases) {
|
||||
let v = expand_tokens(tag, &p, 1, 1);
|
||||
assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})");
|
||||
assert!(!v.is_empty(), "{tag}: the sample photo should give an example");
|
||||
}
|
||||
}
|
||||
assert_eq!(token_example("{seq:3}"), "001");
|
||||
assert_eq!(token_example("{date}"), "20260114");
|
||||
assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848");
|
||||
assert_eq!(token_example("{ext}"), "CR3");
|
||||
assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3");
|
||||
for (d, _) in DATE_DIRECTIVES {
|
||||
let v = format_date("2026-01-14T05:58:48", d);
|
||||
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
|
||||
}
|
||||
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
|
||||
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
|
||||
let json = token_help_json();
|
||||
assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len());
|
||||
assert_eq!(json["tokens"][0]["example"], "IMG_0042");
|
||||
// the command descriptions that list the tokens list all of them
|
||||
for id in ["photo.rename", "photo.renamePreview", "library.import"] {
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) {
|
||||
assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates() {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00");
|
||||
@@ -348,4 +575,22 @@ mod tests {
|
||||
assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name");
|
||||
assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_folder_and_metadata_tokens() {
|
||||
let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF");
|
||||
let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00");
|
||||
p.meta.lens = "24-70mm F2.8".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.meta.creator = "A. Person".into();
|
||||
p.rating = 4;
|
||||
assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF");
|
||||
assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF");
|
||||
// no number at the end of the name / no folder: empty
|
||||
p.file_name = "beach.NEF".into();
|
||||
p.source = Source::Demo { scene: 0 };
|
||||
assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF");
|
||||
// a bare {seq} is padded to the caller's width
|
||||
assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
|
||||
@@ -72,6 +74,10 @@ pub struct SidecarData {
|
||||
pub title: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
/// `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
|
||||
pub copyright_marked: Option<bool>,
|
||||
pub usage_terms: Option<String>,
|
||||
pub copyright_url: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub city: Option<String>,
|
||||
@@ -80,6 +86,9 @@ pub struct SidecarData {
|
||||
pub alt_text: Option<String>,
|
||||
pub extended_description: Option<String>,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
|
||||
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
|
||||
pub captured: Option<String>,
|
||||
pub develop: Option<DevelopPatch>,
|
||||
}
|
||||
|
||||
@@ -100,6 +109,9 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
title: m.title.clone(),
|
||||
caption: m.caption.clone(),
|
||||
copyright: m.copyright.clone(),
|
||||
copyright_marked: m.copyright_marked,
|
||||
usage_terms: m.usage_terms.clone(),
|
||||
copyright_url: m.copyright_url.clone(),
|
||||
creator: m.artist.clone(),
|
||||
location: lc("location").or_else(|| m.sublocation.clone()),
|
||||
city: m.city.clone(),
|
||||
@@ -108,6 +120,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
captured: m.capture_time.map(|d| d.to_iso()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(r) = m.rating {
|
||||
@@ -136,8 +149,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
|
||||
/// Apply sidecar data to a photo record (the sidecar wins, except that a capture time embedded in
|
||||
/// the file is kept). Returns true if develop changed.
|
||||
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
if p.captured.is_none() {
|
||||
p.captured = sc.captured.clone();
|
||||
}
|
||||
if let Some(r) = sc.rating {
|
||||
p.rating = r;
|
||||
}
|
||||
@@ -152,6 +169,8 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
(&sc.title, &mut m.title),
|
||||
(&sc.caption, &mut m.caption),
|
||||
(&sc.copyright, &mut m.copyright),
|
||||
(&sc.usage_terms, &mut m.usage_terms),
|
||||
(&sc.copyright_url, &mut m.copyright_url),
|
||||
(&sc.creator, &mut m.creator),
|
||||
(&sc.location, &mut m.location),
|
||||
(&sc.city, &mut m.city),
|
||||
@@ -164,6 +183,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
*dst = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(marked) = sc.copyright_marked {
|
||||
m.copyright_status = lightcraft_catalog::CopyrightStatus::from_marked(Some(marked));
|
||||
}
|
||||
if let Some(k) = &sc.keywords {
|
||||
m.keywords = k.clone();
|
||||
}
|
||||
@@ -212,6 +234,9 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
|
||||
state: nz(&p.meta.state),
|
||||
country: nz(&p.meta.country),
|
||||
copyright: nz(&p.meta.copyright),
|
||||
copyright_marked: p.meta.copyright_status.marked(),
|
||||
usage_terms: nz(&p.meta.usage_terms),
|
||||
copyright_url: nz(&p.meta.copyright_url),
|
||||
artist: nz(&p.meta.creator),
|
||||
keywords: p.meta.keywords.clone(),
|
||||
rating: Some(p.rating.min(5) as i8),
|
||||
@@ -306,6 +331,9 @@ impl Session {
|
||||
Op::SetLabel { id, label: q.label },
|
||||
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
|
||||
];
|
||||
if q.captured != p.captured {
|
||||
ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
|
||||
}
|
||||
if develop_changed {
|
||||
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,82 @@ pub fn dir(library: &Path) -> PathBuf {
|
||||
library.join("Smart Previews")
|
||||
}
|
||||
|
||||
/// Check that smart previews can be written to `dir`: it must be (or, when its parent exists,
|
||||
/// become) a directory that accepts a new file. Never creates missing parents, so an unplugged
|
||||
/// drive's mount point is not silently recreated on the system drive.
|
||||
pub fn check_writable(dir: &Path) -> Result<(), String> {
|
||||
if !dir.is_dir() {
|
||||
let parent_ok = dir.parent().is_some_and(|p| p.is_dir());
|
||||
if !parent_ok {
|
||||
return Err(format!("{} is not available — reconnect the drive or choose another folder", dir.display()));
|
||||
}
|
||||
std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The number of smart previews in `dir` and their total size in bytes.
|
||||
pub fn stats(dir: &Path) -> (usize, u64) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) };
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "lcsp"))
|
||||
.fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len())))
|
||||
}
|
||||
|
||||
/// What to do with the smart previews in the old folder when the location changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Existing {
|
||||
/// Move them to the new folder (copy and delete across drives).
|
||||
Move,
|
||||
/// Leave them where they are (they stop being used; build again in the new folder).
|
||||
Leave,
|
||||
/// Delete them.
|
||||
Discard,
|
||||
}
|
||||
|
||||
impl Existing {
|
||||
pub fn parse(s: &str) -> Option<Existing> {
|
||||
match s {
|
||||
"move" => Some(Existing::Move),
|
||||
"leave" => Some(Existing::Leave),
|
||||
"discard" => Some(Existing::Discard),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `what` to the smart previews in `from` for the new folder `to` → (moved or removed, failed).
|
||||
pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
let (mut done, mut failed) = (0, Vec::new());
|
||||
if what == Existing::Leave {
|
||||
return (done, failed);
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) };
|
||||
for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) {
|
||||
let src = e.path();
|
||||
let r = match what {
|
||||
Existing::Discard => std::fs::remove_file(&src),
|
||||
_ => {
|
||||
let dst = to.join(e.file_name());
|
||||
if dst.exists() {
|
||||
// named by content: the copy already there is the same preview
|
||||
std::fs::remove_file(&src)
|
||||
} else {
|
||||
std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src)))
|
||||
}
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Ok(()) => done += 1,
|
||||
Err(err) => failed.push(format!("{}: {err}", src.display())),
|
||||
}
|
||||
}
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
/// 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()))?;
|
||||
@@ -80,6 +156,43 @@ pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writable_check_does_not_create_missing_parents() {
|
||||
let base = temp("writable");
|
||||
assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created");
|
||||
assert!(base.join("new").is_dir());
|
||||
let err = check_writable(&base.join("gone/deeper")).unwrap_err();
|
||||
assert!(err.contains("not available"), "{err}");
|
||||
assert!(!base.join("gone").exists(), "no fallback onto the current drive");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_moves_leaves_or_discards() {
|
||||
let base = temp("migrate");
|
||||
let (a, b) = (base.join("a"), base.join("b"));
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join("1.lcsp"), b"one").unwrap();
|
||||
std::fs::write(a.join("note.txt"), b"keep").unwrap();
|
||||
assert_eq!(stats(&a), (1, 3));
|
||||
assert_eq!(migrate(&a, &b, Existing::Leave).0, 0);
|
||||
assert_eq!(stats(&a).0, 1);
|
||||
assert_eq!(migrate(&a, &b, Existing::Move).0, 1);
|
||||
assert_eq!((stats(&a).0, stats(&b).0), (0, 1));
|
||||
assert!(a.join("note.txt").exists(), "only smart previews are touched");
|
||||
assert_eq!(migrate(&b, &a, Existing::Discard).0, 1);
|
||||
assert_eq!(stats(&b).0, 0);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
|
||||
@@ -671,3 +671,35 @@ fn mask_components_invert_duplicate_rename_change_mode_and_delete() {
|
||||
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());
|
||||
}
|
||||
@@ -257,6 +257,37 @@ fn copy_with_destination_organize_rename_and_metadata_preset() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under
|
||||
/// `2026/20260114/` (an undated file by the import time); templates that would leave the
|
||||
/// destination are refused.
|
||||
#[test]
|
||||
fn copy_with_a_folder_template() {
|
||||
let src = temp_dir("tplsrc");
|
||||
let dest = temp_dir("tpldest");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
|
||||
for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] {
|
||||
let r = s.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}),
|
||||
);
|
||||
assert!(r.is_err(), "{bad} should be refused");
|
||||
}
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
|
||||
for d in [&src, &dest] {
|
||||
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]
|
||||
@@ -521,3 +552,95 @@ fn dust_spots_are_found_and_healed() {
|
||||
assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart previews can live outside the library: the folder is saved per library, used for
|
||||
/// building, status and offline rendering, changing it needs an explicit choice for the
|
||||
/// previews already built, and an unavailable folder is an error (never a silent fallback).
|
||||
#[test]
|
||||
fn smart_previews_folder_is_chosen_per_library() {
|
||||
let src = temp_dir("smartloc-src");
|
||||
let lib = temp_dir("smartloc-lib");
|
||||
let away = temp_dir("smartloc-away");
|
||||
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 loc = s.execute("library.smartPreviewsLocation", &json!({})).unwrap();
|
||||
assert_eq!(loc["custom"], false);
|
||||
assert_eq!(loc["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
|
||||
s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 1);
|
||||
|
||||
// relative paths and missing drives are refused, and nothing is created on this drive
|
||||
assert!(s.execute("library.smartPreviewsLocation", &json!({"path": "rel/dir"})).is_err());
|
||||
let gone = away.join("unplugged/previews");
|
||||
let err = s.execute("library.smartPreviewsLocation", &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("not available"), "{err}");
|
||||
assert!(!away.join("unplugged").exists());
|
||||
// the previews already built need an explicit choice
|
||||
let to = away.join("proxies");
|
||||
let err = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("existing"), "{err}");
|
||||
assert_eq!(s.execute("library.smartPreviewsLocation", &json!({})).unwrap()["custom"], false);
|
||||
|
||||
let r = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap();
|
||||
assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}");
|
||||
assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 0);
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true);
|
||||
|
||||
// the setting is saved with the library
|
||||
let mut again = Session::new().with_fs();
|
||||
again.open_library(&lib, false).unwrap();
|
||||
assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
|
||||
|
||||
// offline original: the render comes from the proxy in the chosen folder
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
again.media.forget(id);
|
||||
again.render_now(id, 48, 32).expect("renders from the smart preview in the chosen folder");
|
||||
|
||||
// the chosen drive goes away: building says so and creates nothing on this drive
|
||||
let drive = away.join("drive");
|
||||
std::fs::create_dir_all(&drive).unwrap();
|
||||
let on_drive = drive.join("proxies");
|
||||
again.execute("library.smartPreviewsLocation", &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap();
|
||||
std::fs::remove_dir_all(&drive).unwrap();
|
||||
let loc = again.execute("library.smartPreviewsLocation", &json!({})).unwrap();
|
||||
assert_eq!(loc["available"], false, "{loc}");
|
||||
let err = again.execute("library.smartPreviews", &json!({"ids": [id.0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("not available"), "{err}");
|
||||
assert!(!drive.exists());
|
||||
|
||||
// back to the library's own folder, discarding the proxies left in the first chosen folder
|
||||
let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap();
|
||||
assert_eq!(r["custom"], false, "{r}");
|
||||
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
/// Missing Photos covers library photos only: a small library next to many Local browse
|
||||
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
|
||||
#[test]
|
||||
fn missing_photos_skip_local_browse_records() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..205u64 {
|
||||
let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string();
|
||||
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
p.local = i >= 5; // 5 library photos, 200 seen while browsing
|
||||
p.deleted = i == 4;
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
s.commit("Add", Op::Batch { ops }).unwrap();
|
||||
let mut checked = 0;
|
||||
let lost = crate::cmd::missing::missing_with(&s.catalog, |_| {
|
||||
checked += 1;
|
||||
false
|
||||
});
|
||||
assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked");
|
||||
assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files");
|
||||
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4));
|
||||
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Import → Move: files (and their sidecars) end up at the destination, and a source is removed
|
||||
//! only when its move fully succeeded. Temp dirs only.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::import_move::{Fault, inject};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-move-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write_png(path: &Path, seed: u8) {
|
||||
let (w, h) = (40usize, 24usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn xmp(rating: u8) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new().with_fs();
|
||||
// undated files are filed by the import time
|
||||
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
|
||||
s
|
||||
}
|
||||
|
||||
fn import_move(s: &mut Session, paths: &[&Path], dest: &Path, extra: Value) -> Value {
|
||||
let mut p =
|
||||
json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>(), "mode": "move", "destination": dest.to_string_lossy()});
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
s.execute("library.import", &p).unwrap()
|
||||
}
|
||||
|
||||
fn len(v: &Value, key: &str) -> usize {
|
||||
v[key].as_array().map(Vec::len).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn photo_paths(s: &Session) -> Vec<String> {
|
||||
let mut v: Vec<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn files_under(d: &Path) -> Vec<String> {
|
||||
fn walk(d: &Path, base: &Path, out: &mut Vec<String>) {
|
||||
for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, base, out);
|
||||
} else {
|
||||
out.push(p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(d, d, &mut out);
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Same volume: renamed into the folder template, sidecars (both namings) along, sources gone,
|
||||
/// the catalog points at the new files; undo forgets the photos but leaves the files.
|
||||
#[test]
|
||||
fn move_into_a_folder_template_with_sidecars() {
|
||||
let base = temp_dir("same");
|
||||
let (card, dest) = (base.join("card"), base.join("Photos"));
|
||||
write_png(&card.join("a.png"), 1);
|
||||
std::fs::write(card.join("a.xmp"), xmp(3)).unwrap();
|
||||
write_png(&card.join("b.png"), 2);
|
||||
std::fs::write(card.join("b.png.xmp"), xmp(4)).unwrap();
|
||||
let a_bytes = std::fs::read(card.join("a.png")).unwrap();
|
||||
let mut s = session();
|
||||
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"}));
|
||||
assert_eq!(len(&r, "imported"), 2, "{r}");
|
||||
assert_eq!(len(&r, "moved"), 2, "{r}");
|
||||
assert_eq!(len(&r, "kept"), 0, "{r}");
|
||||
assert_eq!(
|
||||
files_under(&dest),
|
||||
["2026/20260114/20260114_001.png", "2026/20260114/20260114_001.xmp", "2026/20260114/20260114_002.png", "2026/20260114/20260114_002.png.xmp"]
|
||||
);
|
||||
assert_eq!(files_under(&card), Vec::<String>::new(), "the card is empty");
|
||||
assert_eq!(std::fs::read(dest.join("2026/20260114/20260114_001.png")).unwrap(), a_bytes);
|
||||
let day = dest.join("2026").join("20260114");
|
||||
assert_eq!(photo_paths(&s), [day.join("20260114_001.png").to_string_lossy(), day.join("20260114_002.png").to_string_lossy()]);
|
||||
let mut ratings: Vec<u8> = s.catalog.photos().map(|p| p.rating).collect();
|
||||
ratings.sort();
|
||||
assert_eq!(ratings, [3, 4], "the sidecars were read");
|
||||
assert_eq!(r["moved"][0]["sidecars"][0], day.join("20260114_001.xmp").to_string_lossy().as_ref());
|
||||
// undo: the photos leave the library, the moved files stay where they are now
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
assert_eq!(files_under(&dest).len(), 4);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Another volume (no hard links): copied, verified and only then the source removed.
|
||||
#[test]
|
||||
fn move_across_volumes_copies_then_removes() {
|
||||
let base = temp_dir("xvol");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a.png"), 1);
|
||||
std::fs::write(card.join("a.xmp"), xmp(2)).unwrap();
|
||||
let bytes = std::fs::read(card.join("a.png")).unwrap();
|
||||
let mut s = session();
|
||||
inject(Fault::NoLink);
|
||||
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
|
||||
inject(Fault::None);
|
||||
assert_eq!(len(&r, "moved"), 1, "{r}");
|
||||
assert_eq!(files_under(&dest), ["a.png", "a.xmp"]);
|
||||
assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), bytes);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
assert_eq!(std::fs::metadata(dest.join("a.png")).unwrap().nlink(), 1, "a real copy, not a link");
|
||||
}
|
||||
assert!(!card.join("a.png").exists() && !card.join("a.xmp").exists());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A copy that fails while writing, or comes out different, leaves nothing behind and keeps the
|
||||
/// source; nothing is catalogued.
|
||||
#[test]
|
||||
fn a_failed_or_corrupt_copy_keeps_the_source() {
|
||||
for fault in [Fault::FailWrite, Fault::Corrupt] {
|
||||
let base = temp_dir(&format!("fail-{fault:?}"));
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a.png"), 1);
|
||||
std::fs::write(card.join("a.xmp"), xmp(2)).unwrap();
|
||||
let bytes = std::fs::read(card.join("a.png")).unwrap();
|
||||
let mut s = session();
|
||||
inject(fault);
|
||||
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
|
||||
inject(Fault::None);
|
||||
assert_eq!(len(&r, "failed"), 1, "{fault:?}: {r}");
|
||||
assert_eq!(len(&r, "moved"), 0, "{fault:?}: {r}");
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
assert_eq!(std::fs::read(card.join("a.png")).unwrap(), bytes, "{fault:?}: source intact");
|
||||
assert!(card.join("a.xmp").is_file());
|
||||
assert_eq!(files_under(&dest), Vec::<String>::new(), "{fault:?}: no partial copy left");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
/// A source that can't be removed (a read-only card) stays and is reported; the photo is
|
||||
/// imported from its copy.
|
||||
#[test]
|
||||
fn a_source_that_cannot_be_removed_is_kept_and_reported() {
|
||||
let base = temp_dir("ro");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a.png"), 1);
|
||||
let mut s = session();
|
||||
inject(Fault::FailRemove);
|
||||
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
|
||||
inject(Fault::None);
|
||||
assert_eq!(len(&r, "imported"), 1, "{r}");
|
||||
assert_eq!(len(&r, "moved"), 0, "{r}");
|
||||
assert_eq!(len(&r, "kept"), 1, "{r}");
|
||||
assert!(r["kept"][0]["reason"].as_str().unwrap().contains("could not remove"), "{r}");
|
||||
assert!(card.join("a.png").is_file() && dest.join("a.png").is_file());
|
||||
assert_eq!(photo_paths(&s), [dest.join("a.png").to_string_lossy()]);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Duplicates, unreadable files and files that fail (here: a different sidecar already at the
|
||||
/// destination) keep their sources; the rest of the batch still moves.
|
||||
#[test]
|
||||
fn duplicates_and_failures_keep_their_sources() {
|
||||
let base = temp_dir("dup");
|
||||
let (lib_src, card, dest) = (base.join("old"), base.join("card"), base.join("out"));
|
||||
write_png(&lib_src.join("known.png"), 7);
|
||||
let mut s = session();
|
||||
s.execute("library.import", &json!({"paths": [lib_src.to_string_lossy()]})).unwrap();
|
||||
std::fs::create_dir_all(&card).unwrap();
|
||||
std::fs::copy(lib_src.join("known.png"), card.join("same.png")).unwrap(); // same bytes
|
||||
write_png(&card.join("a.png"), 1);
|
||||
std::fs::write(card.join("a.xmp"), xmp(1)).unwrap();
|
||||
write_png(&card.join("b.png"), 2);
|
||||
std::fs::write(card.join("broken.jpg"), "garbage").unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::write(dest.join("a.xmp"), xmp(5)).unwrap(); // someone else's sidecar
|
||||
let r = import_move(&mut s, &[&card], &dest, json!({"organize": "flat"}));
|
||||
assert_eq!(len(&r, "duplicates"), 1, "{r}");
|
||||
assert_eq!(len(&r, "failed"), 2, "{r}"); // broken.jpg (unreadable) and a.png (sidecar clash)
|
||||
assert_eq!(len(&r, "moved"), 1, "{r}");
|
||||
assert_eq!(r["moved"][0]["from"], card.join("b.png").to_string_lossy().as_ref());
|
||||
assert_eq!(files_under(&card), ["a.png", "a.xmp", "broken.jpg", "same.png"]);
|
||||
assert_eq!(files_under(&dest), ["a.xmp", "b.png"], "a.png's copy was taken back");
|
||||
assert_eq!(std::fs::read_to_string(dest.join("a.xmp")).unwrap(), xmp(5), "never overwritten");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A taken name gets -1 (never overwritten), its sidecar follows the new name; a file already
|
||||
/// inside the destination is added where it is, not moved.
|
||||
#[test]
|
||||
fn collisions_are_numbered_and_files_in_the_destination_stay() {
|
||||
let base = temp_dir("coll");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a.png"), 1);
|
||||
std::fs::write(card.join("a.xmp"), xmp(2)).unwrap();
|
||||
write_png(&dest.join("a.png"), 9); // a different photo, same name
|
||||
let theirs = std::fs::read(dest.join("a.png")).unwrap();
|
||||
write_png(&dest.join("inbox/c.png"), 3);
|
||||
let mut s = session();
|
||||
let r = import_move(&mut s, &[&card, &dest.join("inbox")], &dest, json!({"organize": "flat"}));
|
||||
assert_eq!(len(&r, "imported"), 2, "{r}");
|
||||
assert_eq!(len(&r, "moved"), 1, "{r}");
|
||||
assert_eq!(len(&r, "kept"), 1, "{r}");
|
||||
assert_eq!(files_under(&dest), ["a-1.png", "a-1.xmp", "a.png", "inbox/c.png"]);
|
||||
assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), theirs);
|
||||
assert!(photo_paths(&s).contains(&dest.join("inbox").join("c.png").to_string_lossy().to_string()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A stem-named sidecar shared with a photo that isn't moved is copied along but stays beside
|
||||
/// that photo.
|
||||
#[test]
|
||||
fn a_shared_sidecar_stays_with_the_photo_left_behind() {
|
||||
let base = temp_dir("shared");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("x.png"), 1);
|
||||
std::fs::write(card.join("x.tif"), "the other half (not imported)").unwrap();
|
||||
std::fs::write(card.join("x.xmp"), xmp(2)).unwrap();
|
||||
let mut s = session();
|
||||
let r = import_move(&mut s, &[&card.join("x.png")], &dest, json!({"organize": "flat"}));
|
||||
assert_eq!(len(&r, "moved"), 1, "{r}");
|
||||
assert_eq!(files_under(&dest), ["x.png", "x.xmp"]);
|
||||
assert_eq!(files_under(&card), ["x.tif", "x.xmp"]);
|
||||
assert!(r["kept"][0]["reason"].as_str().unwrap().contains("x.tif"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Into the open library's Originals/ (default destination): the records are saved before the
|
||||
/// sources go, and are there after reopening. Move needs somewhere to go and never browses.
|
||||
#[test]
|
||||
fn move_into_the_library_is_saved_before_sources_go() {
|
||||
let base = temp_dir("lib");
|
||||
let (card, lib) = (base.join("card"), base.join("lib"));
|
||||
write_png(&card.join("a.png"), 1);
|
||||
let mut s = session();
|
||||
let p = json!({"paths": [card.to_string_lossy()], "mode": "move"});
|
||||
assert!(s.execute("library.import", &p).is_err(), "no destination, no library");
|
||||
let browse = json!({"paths": [card.to_string_lossy()], "mode": "move", "local": true, "destination": base.join("x").to_string_lossy()});
|
||||
assert!(s.execute("library.import", &browse).is_err(), "browsing never moves");
|
||||
assert!(card.join("a.png").is_file());
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let r = s.execute("library.import", &p).unwrap();
|
||||
assert_eq!(len(&r, "moved"), 1, "{r}");
|
||||
assert!(!card.join("a.png").exists());
|
||||
let want = lib.join("Originals").join("2026").join("2026-01-14").join("a.png");
|
||||
assert!(want.is_file());
|
||||
drop(s);
|
||||
let mut again = Session::new().with_fs();
|
||||
again.open_library(&lib, false).unwrap();
|
||||
assert_eq!(photo_paths(&again), [want.to_string_lossy()]);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -401,3 +401,68 @@ fn keyword_sets_and_recent_keywords() {
|
||||
s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap();
|
||||
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords");
|
||||
}
|
||||
|
||||
/// Auto-Tag from Tracklog: GPS positions from a GPX file by capture time — interpolated within a
|
||||
/// segment, the camera's zone from the photo or `offset`; photos with GPS kept unless `replace`;
|
||||
/// one undo step, replayed from the op log.
|
||||
#[test]
|
||||
fn tracklog_auto_tag() {
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
const GPX: &str = r#"<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
|
||||
<trkpt lat="46.0000" lon="7.0000"><time>2026-05-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="46.0010" lon="7.0020"><time>2026-05-01T10:01:00Z</time></trkpt>
|
||||
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00Z</time></trkpt>
|
||||
</trkseg></trk></gpx>"#;
|
||||
let dir = temp_dir("tracklog");
|
||||
let gpx_path = dir.join("walk.gpx");
|
||||
std::fs::write(&gpx_path, GPX).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.open_library(dir.join("lib"), true).unwrap();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect();
|
||||
let set = |s: &mut Session, id: u64, t: &str| {
|
||||
s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()) }).unwrap();
|
||||
};
|
||||
set(&mut s, ids[0], "2026-05-01T12:00:30"); // camera clock on +02:00, no zone recorded
|
||||
set(&mut s, ids[1], "2026-05-01T12:01:00+02:00"); // zone recorded: exactly on the 2nd point
|
||||
set(&mut s, ids[2], "2026-05-01T15:00:00"); // hours after the log ends
|
||||
set(&mut s, ids[3], "2026-05-01T12:02:00");
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[3]], "gps": [10.0, 20.0]})).unwrap();
|
||||
let gps = |s: &Session, id: u64| s.catalog.photo(PhotoId(id)).unwrap().meta.gps;
|
||||
let before: Vec<_> = ids.iter().map(|i| gps(&s, *i)).collect();
|
||||
let path = gpx_path.to_string_lossy().to_string();
|
||||
// a dry run reports without changing anything
|
||||
let r = s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": "+02:00", "dryRun": true})).unwrap();
|
||||
assert_eq!((r["tagged"].as_u64(), r["interpolated"].as_u64()), (Some(2), Some(2)), "{r}");
|
||||
assert_eq!(r["skipped"], json!({"noTime": 0, "outside": 1, "hasGps": 1}));
|
||||
assert_eq!((r["points"].as_u64(), r["start"].as_str()), (Some(3), Some("2026-05-01T10:00:00Z")));
|
||||
assert_eq!(ids.iter().map(|i| gps(&s, *i)).collect::<Vec<_>>(), before);
|
||||
// tag: one undo step
|
||||
let undo_depth = s.undo.len();
|
||||
s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": 2})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo_depth + 1);
|
||||
assert_eq!(gps(&s, ids[0]), Some((46.0005, 7.001)), "interpolated half-way between the first two points");
|
||||
assert_eq!(gps(&s, ids[1]), Some((46.001, 7.002)));
|
||||
assert_eq!(gps(&s, ids[2]), None);
|
||||
assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)), "existing GPS kept without `replace`");
|
||||
// without an offset, zone-less capture times count as UTC (and are reported)
|
||||
let r = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "replace": true, "dryRun": true})).unwrap();
|
||||
assert_eq!((r["tagged"].as_u64(), r["assumedUtc"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
// replace: the photo that had GPS gets the track's position (its last point, 12:02 local)
|
||||
s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[3]], "offset": "+02:00", "replace": true})).unwrap();
|
||||
assert_eq!(gps(&s, ids[3]), Some((46.002, 7.004)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)));
|
||||
// the op log replays the tags
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(dir.join("lib"), false).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
// errors: no file, not GPX, no timed points, bad offset
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"ids": [ids[0]]})).is_err());
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<kml/>", "ids": [ids[0]]})).is_err());
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<gpx><trk><trkseg/></trk></gpx>", "ids": [ids[0]]})).is_err());
|
||||
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -52,6 +52,57 @@ fn edits_survive_restart_without_close() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Compaction at the threshold runs on a worker thread (issue #37): commands go on (and stay
|
||||
/// durable) meanwhile, the frame loop's `persist_if_dirty` finishes it, and a crash at any time
|
||||
/// keeps every command.
|
||||
#[test]
|
||||
fn threshold_compaction_runs_in_the_background() {
|
||||
let dir = temp_dir("bg-compact");
|
||||
let mut s = open(&dir, true);
|
||||
s.library.as_mut().unwrap().journal_mut().policy = lightcraft_catalog::SnapshotPolicy { max_records: 8, max_bytes: u64::MAX };
|
||||
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
|
||||
let mut background = 0;
|
||||
for k in 0..60usize {
|
||||
s.selection = crate::Selection::single(ids[k % ids.len()]);
|
||||
s.execute("photo.rate", &json!({"rating": k % 6})).unwrap();
|
||||
if s.execute("library.info", &json!({})).unwrap()["persistence"]["snapshotRunning"].as_bool().unwrap() {
|
||||
background += 1;
|
||||
}
|
||||
if k == 30 {
|
||||
// a crash while a compaction may be in flight
|
||||
let expect = s.catalog.to_snapshot();
|
||||
let copy = temp_dir("bg-compact-crash");
|
||||
std::fs::create_dir_all(©).unwrap();
|
||||
for f in ["catalog.snap", "catalog.log"] {
|
||||
if dir.join(f).exists() {
|
||||
std::fs::copy(dir.join(f), copy.join(f)).unwrap();
|
||||
}
|
||||
}
|
||||
let s2 = open(©, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(©);
|
||||
}
|
||||
}
|
||||
assert!(background > 0, "compactions ran in the background");
|
||||
for _ in 0..10_000 {
|
||||
s.persist_if_dirty();
|
||||
if !s.library.as_ref().unwrap().journal().snapshot_running() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
let p = &info["persistence"];
|
||||
assert!(p["snapshots"].as_u64() >= Some(2) && p["lastSnapshot"]["background"] == true, "{p}");
|
||||
// the log holds exactly the records after the snapshot
|
||||
assert_eq!(info["logRecords"].as_u64().unwrap(), info["seq"].as_u64().unwrap() - info["snapshotSeq"].as_u64().unwrap(), "{info}");
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s); // no close: like a crash
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_writes_snapshot_and_view_state() {
|
||||
let dir = temp_dir("close");
|
||||
@@ -121,6 +172,12 @@ fn new_library_without_seed_is_empty_and_compacts() {
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["logRecords"], 0);
|
||||
assert_eq!(info["snapshotSeq"], info["seq"]);
|
||||
// persistence timings (issue #37): the append and the compaction were measured
|
||||
let p = &info["persistence"];
|
||||
assert!(p["appends"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["snapshots"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["lastSnapshot"]["bytes"].as_u64() > Some(0), "{p}");
|
||||
assert!(p["lastSnapshot"]["totalMs"].as_f64() >= p["lastSnapshot"]["serializeMs"].as_f64(), "{p}");
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
||||
assert_eq!(s2.catalog.albums().count(), 1);
|
||||
|
||||
@@ -21,7 +21,7 @@ fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
|
||||
fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
let dir = temp_dir("hdr");
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, b).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
@@ -71,7 +71,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
fn panorama_command_with_auto_crop() {
|
||||
let dir = temp_dir("pano");
|
||||
let mut paths = Vec::new();
|
||||
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() {
|
||||
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() {
|
||||
let img = v.to_srgb8();
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
let p = dir.join(format!("P_{i}.png"));
|
||||
|
||||
@@ -176,3 +176,50 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
|
||||
let dir = temp_dir("chips");
|
||||
let mut s = open(&dir);
|
||||
let all = s.visible_cloned().len();
|
||||
assert!(all > 1);
|
||||
assert_eq!(s.source_total(), Some(all));
|
||||
s.execute("library.filter", &json!({"rating": 4, "date": "2026-01-16", "text": "x"})).unwrap();
|
||||
// the total is the source's, whatever the filter hides
|
||||
assert_eq!(s.source_total(), Some(all));
|
||||
let chips = crate::filter_chips(&s.filter, &s.catalog);
|
||||
let labels: Vec<&str> = chips.iter().map(|c| c.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Search: “x”", "Rating ≥ 4★", "Date: January 16, 2026"]);
|
||||
// clearing the date chip leaves the other two filters alone
|
||||
s.execute("library.filter", &chips[2].clear).unwrap();
|
||||
assert_eq!(s.filter.date, None);
|
||||
assert_eq!((s.filter.rating, s.filter.text.as_str()), (4, "x"));
|
||||
for c in crate::filter_chips(&s.filter, &s.catalog) {
|
||||
s.execute("library.filter", &c.clear).unwrap();
|
||||
}
|
||||
assert_eq!(s.filter, Default::default());
|
||||
}
|
||||
@@ -202,3 +202,62 @@ fn metadata_presets_apply_to_photos_and_imports() {
|
||||
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);
|
||||
}
|
||||
@@ -445,3 +445,134 @@ fn dng_export_compression_choices() {
|
||||
}
|
||||
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
|
||||
}
|
||||
|
||||
fn write_jpeg(path: &Path, capture: Option<&str>) {
|
||||
let (w, h) = (32usize, 24usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let exif = capture.map(|c| {
|
||||
lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() })
|
||||
});
|
||||
let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||||
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
const DATED_SIDECAR: &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:exif="http://ns.adobe.com/exif/1.0/"
|
||||
exif:DateTimeOriginal="2026-01-14T05:58:48" />
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn sidecar_capture_time_fills_in_when_the_file_has_none() {
|
||||
let src = temp_dir("sidecar-date");
|
||||
write_jpeg(&src.join("sample.jpg"), None);
|
||||
std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap();
|
||||
// the embedded capture time wins over the sidecar's
|
||||
write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09"));
|
||||
std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap();
|
||||
let lib = temp_dir("sidecar-date-lib");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// copy: the sidecar's date also files the copy
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
|
||||
assert_eq!(r["sidecars"], 2, "{r}");
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone();
|
||||
let sample = by_name(&s, "sample.jpg");
|
||||
assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48"));
|
||||
let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") };
|
||||
assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}");
|
||||
assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09"));
|
||||
|
||||
// Read Metadata from File fills in a missing capture time too
|
||||
let id = sample.id;
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();
|
||||
std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48"));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one
|
||||
/// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights
|
||||
/// Management fields and read back on a fresh import.
|
||||
#[test]
|
||||
fn copyright_status_usage_terms_and_url_round_trip() {
|
||||
use lightcraft_catalog::CopyrightStatus;
|
||||
let src = temp_dir("rights-src");
|
||||
let lib = temp_dir("rights-lib");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (a, b) = (id(&s, "a.png"), id(&s, "b.png"));
|
||||
let meta = |s: &Session, id: PhotoId| s.catalog.photo(id).unwrap().meta.clone();
|
||||
assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown);
|
||||
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute(
|
||||
"photo.setMeta",
|
||||
&json!({"copyright": "© 2026 A. Person", "copyrightStatus": "copyrighted", "usageTerms": "Editorial use only", "copyrightUrl": "https://example.com/rights"}),
|
||||
)
|
||||
.unwrap();
|
||||
let m = meta(&s, a);
|
||||
assert_eq!(
|
||||
(m.copyright_status, m.usage_terms.as_str(), m.copyright_url.as_str()),
|
||||
(CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights")
|
||||
);
|
||||
assert!(s.execute("photo.setMeta", &json!({"copyrightStatus": "maybe"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown, "one undo step");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// copy → paste onto b
|
||||
let clip = s.execute("photo.copyMetadata", &json!({})).unwrap();
|
||||
assert_eq!(clip["copyrightStatus"], "copyrighted");
|
||||
s.execute("photo.pasteMetadata", &json!({"ids": [b.0], "fields": ["copyrightStatus", "usageTerms"]})).unwrap();
|
||||
let mb = meta(&s, b);
|
||||
assert_eq!((mb.copyright_status, mb.usage_terms.as_str(), mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "Editorial use only", ""));
|
||||
// a metadata preset from the active photo carries the copyright fields; applying it sets them
|
||||
s.execute("metadata.savePreset", &json!({"name": "Rights"})).unwrap();
|
||||
let presets = s.execute("metadata.presets", &json!({})).unwrap();
|
||||
assert_eq!(presets[0]["fields"]["copyrightStatus"], "copyrighted", "{presets}");
|
||||
assert_eq!(presets[0]["fields"]["copyrightUrl"], "https://example.com/rights");
|
||||
s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "publicDomain", "usageTerms": ""})).unwrap();
|
||||
s.execute("metadata.applyPreset", &json!({"name": "Rights", "ids": [b.0]})).unwrap();
|
||||
let mb = meta(&s, b);
|
||||
assert_eq!((mb.copyright_status, mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "https://example.com/rights"));
|
||||
assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"copyrightStatus": "sort of"}})).is_err());
|
||||
// b becomes public domain; smart-album rule on the status
|
||||
s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "public domain"})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&json!({"name": "PD", "rules": {"ruleSet": {"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
// sidecars: XMP Rights Management fields, read back by a fresh library
|
||||
s.execute("photo.saveMetadataToFile", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let xmp = std::fs::read_to_string(src.join("a.xmp")).unwrap();
|
||||
assert!(xmp.contains("<xmpRights:Marked>True</xmpRights:Marked>"), "{xmp}");
|
||||
assert!(xmp.contains("<xmpRights:WebStatement>https://example.com/rights</xmpRights:WebStatement>"), "{xmp}");
|
||||
assert!(std::fs::read_to_string(src.join("b.xmp")).unwrap().contains("<xmpRights:Marked>False</xmpRights:Marked>"));
|
||||
let expect_a = meta(&s, a);
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(&lib).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let ma = meta(&s, id(&s, "a.png"));
|
||||
assert_eq!(
|
||||
(ma.copyright.as_str(), ma.copyright_status, ma.usage_terms.as_str(), ma.copyright_url.as_str()),
|
||||
(expect_a.copyright.as_str(), CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights")
|
||||
);
|
||||
assert_eq!(meta(&s, id(&s, "b.png")).copyright_status, CopyrightStatus::PublicDomain);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -108,3 +108,96 @@ impl Selection {
|
||||
self.active = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// One active filter, shown as a removable chip above the grid.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FilterChip {
|
||||
pub label: String,
|
||||
/// `library.filter` patch that clears just this filter.
|
||||
pub clear: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The chips for every constraint in the filter bar / search / sidebar (not the source itself).
|
||||
pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
use lightcraft_catalog::{MediaKind, RatingOp};
|
||||
use serde_json::{Value::Null, json};
|
||||
let mut v = Vec::new();
|
||||
let mut add = |label: String, clear: serde_json::Value| v.push(FilterChip { label, clear });
|
||||
if !f.text.trim().is_empty() {
|
||||
add(format!("Search: “{}”", f.text.trim()), json!({"text": ""}));
|
||||
}
|
||||
if f.rating > 0 {
|
||||
let op = match f.rating_op {
|
||||
RatingOp::AtLeast => "≥",
|
||||
RatingOp::Exactly => "=",
|
||||
RatingOp::AtMost => "≤",
|
||||
};
|
||||
add(format!("Rating {op} {}★", f.rating), json!({"rating": 0}));
|
||||
}
|
||||
if let Some(fl) = f.flag {
|
||||
add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null}));
|
||||
}
|
||||
let mut labels = f.labels.clone();
|
||||
labels.extend(f.label.filter(|l| !labels.contains(l)));
|
||||
if !labels.is_empty() {
|
||||
let names: Vec<String> = labels.iter().map(|l| cat.label_name(*l)).collect();
|
||||
add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []}));
|
||||
}
|
||||
if let Some(k) = f.kind {
|
||||
let n = match k {
|
||||
MediaKind::Image => "Photos",
|
||||
MediaKind::Raw => "Raw",
|
||||
MediaKind::Video => "Videos",
|
||||
};
|
||||
add(format!("Type: {n}"), json!({"kind": Null}));
|
||||
}
|
||||
if let Some(m) = &f.merged {
|
||||
let n = match m.as_str() {
|
||||
"hdr" => "HDR",
|
||||
"panorama" => "Panoramas",
|
||||
"hdrPanorama" => "HDR panoramas",
|
||||
_ => "Merged",
|
||||
};
|
||||
add(format!("Type: {n}"), json!({"merged": Null}));
|
||||
}
|
||||
if let Some(e) = f.edited {
|
||||
add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null}));
|
||||
}
|
||||
for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] {
|
||||
if let Some(x) = val {
|
||||
add(format!("{name}: {x}"), json!({key: Null}));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &f.date {
|
||||
add(format!("Date: {}", date_label(d)), json!({"date": Null}));
|
||||
}
|
||||
if let Some(d) = &f.imported {
|
||||
add(format!("Imported: {}", date_label(d)), json!({"imported": Null}));
|
||||
}
|
||||
match (&f.date_from, &f.date_to) {
|
||||
(Some(a), Some(b)) => add(format!("Captured {a} – {b}"), json!({"dateFrom": Null, "dateTo": Null})),
|
||||
(Some(a), None) => add(format!("Captured from {a}"), json!({"dateFrom": Null})),
|
||||
(None, Some(b)) => add(format!("Captured until {b}"), json!({"dateTo": Null})),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null}));
|
||||
}
|
||||
if !f.only.is_empty() {
|
||||
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `2026-01-16` → "January 16, 2026"; `2026-01` → "January 2026"; anything else as is.
|
||||
fn date_label(d: &str) -> String {
|
||||
const MONTHS: [&str; 12] =
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
let parts: Vec<&str> = d.split('-').collect();
|
||||
let month = parts.get(1).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
|
||||
match (parts.as_slice(), month) {
|
||||
([y, _, day], Some(m)) => format!("{m} {}, {y}", day.trim_start_matches('0')),
|
||||
([y, _], Some(m)) => format!("{m} {y}"),
|
||||
_ => d.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped,
|
||||
//! oriented) image to `0..1` on both axes, so tools and settings are resolution independent.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod crop;
|
||||
mod homography;
|
||||
|
||||
+21
-3
@@ -86,6 +86,9 @@ pub struct Buf {
|
||||
|
||||
impl Buf {
|
||||
pub fn raw(&self) -> &wgpu::Buffer {
|
||||
// `buf` is `Some` from construction (`Gpu::buffer`/`upload`) until `Drop` takes it back
|
||||
// for the pool, so a live `Buf` always has it.
|
||||
#[allow(clippy::expect_used)]
|
||||
&self.buf.as_ref().expect("live buffer").0
|
||||
}
|
||||
}
|
||||
@@ -442,7 +445,12 @@ impl Gpu {
|
||||
|
||||
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
|
||||
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
|
||||
let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}"));
|
||||
let Some(k) = self.kernels.get(name) else {
|
||||
// a kernel name typo: fail this render over to the CPU instead of crashing
|
||||
log::error!("gpu: unknown kernel {name}");
|
||||
crate::mark_broken();
|
||||
return;
|
||||
};
|
||||
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
|
||||
let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -479,11 +487,21 @@ impl Gpu {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
// A failed readback (device lost, out of memory) marks the GPU broken: the caller
|
||||
// (`crate::render`) then discards this render's zeroed result and renders on the CPU.
|
||||
let failed = |why: String| {
|
||||
log::error!("gpu readback failed: {why}");
|
||||
crate::mark_broken();
|
||||
vec![<T as bytemuck::Zeroable>::zeroed(); len]
|
||||
};
|
||||
match rx.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
other => panic!("gpu readback failed: {other:?}"),
|
||||
other => return failed(format!("{other:?}")),
|
||||
}
|
||||
let data = slice.get_mapped_range().expect("mapped");
|
||||
let data = match slice.get_mapped_range() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return failed(format!("{e:?}")),
|
||||
};
|
||||
let out: Vec<T> = bytemuck::cast_slice(&data).to_vec();
|
||||
drop(data);
|
||||
staging.unmap();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here
|
||||
//! compiles to the CPU fallback on wasm32.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
+12
-10
@@ -86,15 +86,17 @@ pub fn finish_fields() -> Vec<(&'static str, usize)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn index(name: &str) -> usize {
|
||||
/// Offset of field `name`; `None` (and a debug assertion) for a name not in [`FIELDS`].
|
||||
fn index(name: &str) -> Option<usize> {
|
||||
let mut i = 0;
|
||||
for (n, len) in FIELDS {
|
||||
if *n == name {
|
||||
return i;
|
||||
return Some(i);
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
panic!("unknown finish field {name}")
|
||||
debug_assert!(false, "unknown finish field {name}");
|
||||
None
|
||||
}
|
||||
|
||||
/// A parameter block being filled by name.
|
||||
@@ -105,20 +107,20 @@ impl Block {
|
||||
Block(vec![0; FIELDS.iter().map(|f| f.1).sum()])
|
||||
}
|
||||
fn f(&mut self, name: &str, v: f32) {
|
||||
let i = index(name);
|
||||
self.0[i] = v.to_bits();
|
||||
self.u(name, v.to_bits());
|
||||
}
|
||||
fn u(&mut self, name: &str, v: u32) {
|
||||
let i = index(name);
|
||||
self.0[i] = v;
|
||||
if let Some(slot) = index(name).and_then(|i| self.0.get_mut(i)) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
fn b(&mut self, name: &str, v: bool) {
|
||||
self.u(name, v as u32);
|
||||
}
|
||||
fn fs(&mut self, name: &str, v: &[f32]) {
|
||||
let i = index(name);
|
||||
for (k, x) in v.iter().enumerate() {
|
||||
self.0[i + k] = x.to_bits();
|
||||
let Some(i) = index(name) else { return };
|
||||
for (slot, x) in self.0.iter_mut().skip(i).zip(v) {
|
||||
*slot = x.to_bits();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-11
@@ -402,17 +402,19 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
||||
Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d),
|
||||
None => (oriented, (sp.ow, sp.oh)),
|
||||
};
|
||||
match &sp.mode {
|
||||
SampleMode::Copy => base,
|
||||
SampleMode::Affine(xf) => {
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
|
||||
p.extend(affine_bits(xf));
|
||||
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
}
|
||||
SampleMode::Warp(o2t) => {
|
||||
let wp = fr.warp.as_ref().expect("warp mode has a warp");
|
||||
let mut affine = |xf: &lightcraft_geom::Affine| {
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let mut p = vec![bw as u32, bh as u32, w as u32, h as u32];
|
||||
p.extend(affine_bits(xf));
|
||||
cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
};
|
||||
match (&sp.mode, fr.warp.as_ref()) {
|
||||
(SampleMode::Copy, _) => base,
|
||||
(SampleMode::Affine(xf), _) => affine(xf),
|
||||
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
|
||||
(SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)),
|
||||
(SampleMode::Warp(o2t), Some(wp)) => {
|
||||
let out = cx.gpu.buffer(w * h * 3);
|
||||
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
|
||||
cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16]));
|
||||
|
||||
@@ -210,4 +210,23 @@ mod tests {
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The export file-name template takes the Rename Photos tokens.
|
||||
#[test]
|
||||
fn export_naming_tokens() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-export-naming-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut h = Headless::demo();
|
||||
let ids: Vec<u64> = h.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
for (id, title) in ids.iter().zip(["Harbour", "Hills"]) {
|
||||
h.call("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [id], "title": title}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 4}})).unwrap();
|
||||
}
|
||||
let params = json!({"dir": dir.to_string_lossy(), "ids": ids, "longEdge": 32, "format": "png", "naming": "{title}_{rating}star_{seq:2}", "startNumber": 5});
|
||||
let r = h.call("engine.execute", json!({"command": "app.export", "params": params})).unwrap();
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["Harbour_4star_05.png", "Hills_4star_06.png"], "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//!
|
||||
//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod backend;
|
||||
mod base64;
|
||||
|
||||
@@ -72,6 +72,47 @@ fn tools_list_has_helpers_and_every_command() {
|
||||
assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_")));
|
||||
}
|
||||
|
||||
/// The `import` helper passes the copy options through: a folder template files the copy.
|
||||
#[test]
|
||||
fn import_tool_copies_with_a_folder_template() {
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-import-tpl-{}", 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();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(src.join("a.png"), png).unwrap();
|
||||
let mut b = Headless::demo();
|
||||
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
|
||||
let args =
|
||||
json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"});
|
||||
let r = call_tool(&mut b, "import", &args);
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-import-move-{}", 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();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(src.join("sample.png"), png).unwrap();
|
||||
let mut b = Headless::demo();
|
||||
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
|
||||
let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(),
|
||||
"organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"});
|
||||
let r = call_tool(&mut b, "import", &args);
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert!(dest.join("2026").join("20260114").join("20260114_001.png").is_file());
|
||||
assert!(!src.join("sample.png").exists(), "moved");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_tools_run_commands() {
|
||||
let mut b = Headless::demo();
|
||||
@@ -102,6 +143,18 @@ fn set_develop_values_and_settings() {
|
||||
assert!(call_tool(&mut b, "set_develop", &json!({"values": {"no.such": 1}})).is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_needs_a_rect_angle_or_reset() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
// A guessed parameter must not succeed silently.
|
||||
let r = call_tool(&mut b, "crop", &json!({"id": first.0, "aspect": "1:1"}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(call_tool(&mut b, "crop", &json!({"id": first.0, "reset": false})).is_error);
|
||||
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "rect": [0.1, 0.0, 0.9, 1.0]})).is_error);
|
||||
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "reset": true})).is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_tools_need_the_app() {
|
||||
let mut b = Headless::demo();
|
||||
|
||||
+19
-5
@@ -96,7 +96,14 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"import",
|
||||
"Import photos",
|
||||
"Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.",
|
||||
json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}),
|
||||
json!({
|
||||
"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"},
|
||||
"album": {"type": "integer", "description": "Also add to this album id"},
|
||||
"mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"},
|
||||
"destination": {"type": "string", "description": "Copy or move: destination folder"},
|
||||
"organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"},
|
||||
"rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"}
|
||||
}),
|
||||
&["paths"],
|
||||
),
|
||||
tool(
|
||||
@@ -154,7 +161,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
tool(
|
||||
"crop",
|
||||
"Crop / straighten",
|
||||
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it.",
|
||||
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it. Give at least one of them.",
|
||||
json!({"id": photo_id, "rect": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "angle": {"type": "number"}, "reset": {"type": "boolean"}}),
|
||||
&[],
|
||||
),
|
||||
@@ -192,7 +199,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
|
||||
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
|
||||
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
|
||||
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
|
||||
"naming": {"type": "string", "description": "File-name template, e.g. {name}-{seq} or {date:%Y-%m-%d}_{title}_{seq:2} (tokens: {name} {num} {seq} {seq:N} {date} {date:%…} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext})"},
|
||||
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
@@ -453,8 +461,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
return ToolResult::error(format!("no photos found in {paths:?}"));
|
||||
}
|
||||
let mut p = json!({"paths": files});
|
||||
if let Some(a) = args.get("album") {
|
||||
p["album"] = a.clone();
|
||||
for k in ["album", "mode", "destination", "organize", "rename"] {
|
||||
if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
ToolResult::from(exec(b, "library.import", p).map(|mut r| {
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
@@ -515,6 +525,9 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
}
|
||||
None => ToolResult::error("missing `preset`"),
|
||||
},
|
||||
"crop" if args.get("rect").is_none() && args.get("angle").is_none() && args.get("reset").and_then(Value::as_bool) != Some(true) => {
|
||||
ToolResult::error("give `rect`, `angle` or `reset: true`")
|
||||
}
|
||||
"crop" => ToolResult::from(activate(b, args).and_then(|_| {
|
||||
if args.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
exec(b, "crop.reset", json!({}))
|
||||
@@ -547,6 +560,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
|
||||
@@ -97,7 +97,8 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz
|
||||
}
|
||||
}
|
||||
let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() };
|
||||
let (name, f) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97).expect("the best fit qualifies");
|
||||
// the fit with `most` inliers always qualifies
|
||||
let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() };
|
||||
Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale }
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ impl Features {
|
||||
pub fn detect(img: &Plane, max: usize) -> Features {
|
||||
let mut levels = vec![img.clone()];
|
||||
while levels.len() < 4 {
|
||||
let l = levels.last().expect("non-empty");
|
||||
let Some(l) = levels.last() else { break };
|
||||
if l.width.min(l.height) < 96 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! Every algorithm here is our own implementation from published papers (cited in each module);
|
||||
//! no code from GPL projects was consulted.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod align;
|
||||
pub mod features;
|
||||
|
||||
@@ -106,14 +106,14 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) {
|
||||
}
|
||||
pm.data[i * (c + 1) + c] = a;
|
||||
}
|
||||
let mut levels = vec![pm];
|
||||
while levels.last().is_some_and(|l| l.w > 1 || l.h > 1) {
|
||||
let d = down(levels.last().expect("level"));
|
||||
levels.push(d);
|
||||
// the finer levels, and the coarsest (1 × 1) one
|
||||
let (mut levels, mut l) = (Vec::new(), pm);
|
||||
while l.w > 1 || l.h > 1 {
|
||||
let d = down(&l);
|
||||
levels.push(std::mem::replace(&mut l, d));
|
||||
}
|
||||
// normalise the coarsest, then fill each finer level's holes from the coarser one
|
||||
let mut filled = {
|
||||
let l = levels.last().expect("level");
|
||||
let mut f = Buf::new(l.w, l.h, c);
|
||||
for i in 0..l.w * l.h {
|
||||
let a = l.data[i * (c + 1) + c];
|
||||
@@ -123,8 +123,7 @@ pub fn push_pull(vals: &mut Buf, wgt: &[f32]) {
|
||||
}
|
||||
f
|
||||
};
|
||||
for li in (0..levels.len() - 1).rev() {
|
||||
let l = &levels[li];
|
||||
for l in levels.iter().rev() {
|
||||
let coarse = up(&filled, l.w, l.h);
|
||||
let mut f = Buf::new(l.w, l.h, c);
|
||||
for i in 0..l.w * l.h {
|
||||
@@ -235,7 +234,8 @@ impl Blender {
|
||||
}
|
||||
res = Some(b);
|
||||
}
|
||||
res.expect("level 0")
|
||||
// `0..=levels` always yields level 0
|
||||
res.unwrap_or_else(|| Buf::new(self.w, self.h, 3))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn initial_cameras(ids: &[usize], sizes: &[(usize, usize)], pairs: &[PairMat
|
||||
let s = d.signum() * d.abs().cbrt();
|
||||
let rel = rel.map(|r| r.map(|v| v / s));
|
||||
let rel = linalg::orthonormalize(&rel);
|
||||
let rk = cams[known].expect("known").r;
|
||||
let Some(rk) = cams[known].map(|c| c.r) else { continue };
|
||||
cams[new] = Some(Camera { f: f0, r: linalg::mul3(&rel, &rk), cx: c(new).0, cy: c(new).1 });
|
||||
grew = true;
|
||||
}
|
||||
@@ -277,7 +277,9 @@ pub fn bundle_adjust(cams: &[Option<Camera>], pairs: &[PairMatch], anchor: usize
|
||||
let cost = |cs: &[Option<Camera>]| -> (f64, f64) {
|
||||
let (mut c, mut sq) = (0.0, 0.0);
|
||||
for o in &obs {
|
||||
let r = residual(cs[o.i].as_ref().expect("cam"), cs[o.j].as_ref().expect("cam"), o.a, o.b);
|
||||
// every observation joins two posed cameras (see `obs` above)
|
||||
let (Some(ci), Some(cj)) = (cs[o.i].as_ref(), cs[o.j].as_ref()) else { continue };
|
||||
let r = residual(ci, cj, o.a, o.b);
|
||||
for v in r {
|
||||
let a = v.abs();
|
||||
sq += v * v;
|
||||
@@ -298,11 +300,12 @@ pub fn bundle_adjust(cams: &[Option<Camera>], pairs: &[PairMatch], anchor: usize
|
||||
.fold(
|
||||
|| (vec![0.0f64; np * np], vec![0.0f64; np]),
|
||||
|(mut jtj, mut jtr), o| {
|
||||
let (ci, cj) = (cur[o.i].as_ref().expect("cam"), cur[o.j].as_ref().expect("cam"));
|
||||
let (Some(ci), Some(cj)) = (cur[o.i].as_ref(), cur[o.j].as_ref()) else { return (jtj, jtr) };
|
||||
let r = residual(ci, cj, o.a, o.b);
|
||||
let mut cols: Vec<(usize, [f64; 4])> = Vec::with_capacity(8);
|
||||
for (cam, which) in [(o.i, 0), (o.j, 1)] {
|
||||
let base = 4 * idx[cam].expect("idx");
|
||||
let Some(i) = idx[cam] else { continue };
|
||||
let base = 4 * i;
|
||||
for k in 0..4 {
|
||||
if k < 3 && cam == anchor {
|
||||
continue;
|
||||
|
||||
@@ -223,7 +223,7 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re
|
||||
Projection::Auto => choose_projection(&frames, &cams),
|
||||
p => p,
|
||||
};
|
||||
let fmed = median(used.iter().map(|&i| cams[i].expect("used").f).collect());
|
||||
let fmed = median(used.iter().filter_map(|&i| cams[i].map(|c| c.f)).collect());
|
||||
let Some((bx0, by0, bx1, by1)) = bounds(&frames, &cams, projection, fmed) else {
|
||||
return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0)));
|
||||
};
|
||||
|
||||
+16
-11
@@ -8,9 +8,13 @@ use lightcraft_raster::Rgb32f;
|
||||
use crate::frame::FrameColor;
|
||||
use crate::linalg::{self, M3};
|
||||
use crate::output::{DngSamples, write_linear_dng};
|
||||
use crate::{MergeError, Result};
|
||||
|
||||
fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene {
|
||||
lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library")
|
||||
fn scene(kind: lightcraft_scenes::Kind) -> Result<lightcraft_scenes::Scene> {
|
||||
lightcraft_scenes::demo_library()
|
||||
.into_iter()
|
||||
.find(|s| s.kind == kind)
|
||||
.ok_or_else(|| MergeError::Mismatch(format!("no {kind:?} scene in the demo library")))
|
||||
}
|
||||
|
||||
fn srgb_color() -> FrameColor {
|
||||
@@ -19,12 +23,12 @@ fn srgb_color() -> FrameColor {
|
||||
|
||||
/// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each
|
||||
/// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels.
|
||||
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec<Vec<u8>> {
|
||||
pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result<Vec<Vec<u8>>> {
|
||||
let m = 12usize;
|
||||
let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m);
|
||||
let truth = scene(lightcraft_scenes::Kind::OceanSunset)?.render(w + 2 * m, h + 2 * m);
|
||||
let mut l: Vec<f32> = truth.data.iter().map(|p| p[1]).collect();
|
||||
l.sort_by(|a, b| a.total_cmp(b));
|
||||
let s = 0.18 / l[l.len() / 2].max(1e-6);
|
||||
let s = 0.18 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
|
||||
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
|
||||
evs.iter()
|
||||
.enumerate()
|
||||
@@ -44,20 +48,21 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec<Vec<u8>> {
|
||||
iso: Some(100),
|
||||
..Default::default()
|
||||
};
|
||||
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode")
|
||||
write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used
|
||||
/// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB.
|
||||
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec<Rgb32f> {
|
||||
let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840);
|
||||
pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Result<Vec<Rgb32f>> {
|
||||
let env = scene(lightcraft_scenes::Kind::Canyon)?.render(2400, 840);
|
||||
let mut l: Vec<f32> = env.data.iter().map(|p| p[1]).collect();
|
||||
l.sort_by(|a, b| a.total_cmp(b));
|
||||
let s = 0.25 / l[l.len() / 2].max(1e-6);
|
||||
let s = 0.25 / l.get(l.len() / 2).copied().unwrap_or(0.0).max(1e-6);
|
||||
let (theta, phi) = (200.0f64, 70.0f64);
|
||||
yaws.iter()
|
||||
Ok(yaws
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, yaw)| {
|
||||
let wob = (i as f64 * 1.3).sin();
|
||||
@@ -77,5 +82,5 @@ pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec<Rgb32f> {
|
||||
env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! GPS track logs in GPX 1.0 / 1.1 (the open GPS Exchange Format, <https://www.topografix.com/gpx.asp>):
|
||||
//! the timed track points of every `<trk>`, and the position at any moment for geotagging photos.
|
||||
//!
|
||||
//! What we use from the format: `<trk>` → `<trkseg>` → `<trkpt lat=… lon=…>` with optional `<ele>`
|
||||
//! (metres) and `<time>` (an `xsd:dateTime`, UTC per the schema — a time without a zone is read as
|
||||
//! UTC). Points without a time can't be matched to a photo and are left out; waypoints (`<wpt>`) and
|
||||
//! routes (`<rte>`) carry no timeline and are ignored. The schema defines a track segment as one
|
||||
//! continuous span of logging, so positions are only interpolated *within* a segment, never across
|
||||
//! the gap where reception was lost or the logger was off.
|
||||
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::{DateTime, Gps};
|
||||
|
||||
/// Nesting / size limits (a track log is flat; these only stop hostile input).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_POINTS: usize = 5_000_000;
|
||||
|
||||
/// One timed point of a track.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TrackPoint {
|
||||
/// Seconds since 1970-01-01T00:00:00Z (fractional when the log has sub-second times).
|
||||
pub time: f64,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
/// Metres above sea level.
|
||||
pub elevation: Option<f64>,
|
||||
/// Track segment number, counted across the whole file (continuous logging spans).
|
||||
pub segment: u32,
|
||||
}
|
||||
|
||||
/// How a position was found by [`Tracklog::locate`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Match {
|
||||
/// The time falls between two points of the same segment: linear interpolation.
|
||||
Interpolated,
|
||||
/// The nearest point (at a segment's start or end, or across a gap).
|
||||
Nearest,
|
||||
}
|
||||
|
||||
/// The timed points of a GPX file, sorted by time.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Tracklog {
|
||||
pub points: Vec<TrackPoint>,
|
||||
/// Tracks (`<trk>`) in the file.
|
||||
pub tracks: usize,
|
||||
/// Track points without a usable time (left out of `points`).
|
||||
pub untimed: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GpxError {
|
||||
Xml(String),
|
||||
/// Not a GPX document (no `<gpx>` root).
|
||||
NotGpx,
|
||||
/// Nesting / size limits exceeded.
|
||||
TooComplex,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GpxError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
GpxError::Xml(e) => write!(f, "malformed GPX: {e}"),
|
||||
GpxError::NotGpx => f.write_str("not a GPX file (no <gpx> element)"),
|
||||
GpxError::TooComplex => f.write_str("GPX file too large or too deeply nested"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GpxError {}
|
||||
|
||||
/// Local name of a possibly prefixed XML name (`gpx:trkpt` → `trkpt`).
|
||||
fn local(name: &[u8]) -> &[u8] {
|
||||
name.rsplit(|&b| b == b':').next().unwrap_or(name)
|
||||
}
|
||||
|
||||
/// An `xsd:dateTime` as seconds since the Unix epoch; no zone = UTC (the GPX schema's convention).
|
||||
fn parse_time(s: &str) -> Option<f64> {
|
||||
let mut d = DateTime::parse_iso(s)?;
|
||||
// a bare date (no time of day) is too coarse to place a photo
|
||||
if !s.contains('T') {
|
||||
return None;
|
||||
}
|
||||
d.offset_minutes.get_or_insert(0);
|
||||
Some(d.unix_seconds() as f64 + d.millis as f64 / 1000.0)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
ele: Option<f64>,
|
||||
time: Option<f64>,
|
||||
}
|
||||
|
||||
/// Parse a GPX document.
|
||||
pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
|
||||
let mut reader = quick_xml::Reader::from_str(s);
|
||||
reader.config_mut().check_end_names = false;
|
||||
let mut path: Vec<Vec<u8>> = Vec::new();
|
||||
let mut seen_gpx = false;
|
||||
let mut log = Tracklog::default();
|
||||
let mut segment: u32 = 0;
|
||||
let mut pt: Option<Pending> = None;
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
let ev = reader.read_event().map_err(|e| GpxError::Xml(e.to_string()))?;
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let name = local(e.name().as_ref()).to_vec();
|
||||
let parent = path.last().map(Vec::as_slice);
|
||||
match name.as_slice() {
|
||||
b"gpx" if path.is_empty() => seen_gpx = true,
|
||||
b"trk" if parent == Some(b"gpx") => log.tracks += 1,
|
||||
b"trkseg" if parent == Some(b"trk") => {
|
||||
if log.points.last().is_some_and(|p| p.segment == segment) {
|
||||
segment += 1;
|
||||
}
|
||||
}
|
||||
b"trkpt" if parent == Some(b"trkseg") => {
|
||||
let mut p = Pending::default();
|
||||
for a in e.attributes().with_checks(false).flatten() {
|
||||
let v = String::from_utf8_lossy(&a.value);
|
||||
match local(a.key.as_ref()) {
|
||||
b"lat" => p.lat = v.trim().parse().ok(),
|
||||
b"lon" => p.lon = v.trim().parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pt = Some(p);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
text.clear();
|
||||
if matches!(ev, Event::Start(_)) {
|
||||
if path.len() >= MAX_DEPTH {
|
||||
return Err(GpxError::TooComplex);
|
||||
}
|
||||
path.push(name);
|
||||
} else if name == b"trkpt" {
|
||||
finish_point(&mut log, pt.take(), segment)?;
|
||||
}
|
||||
}
|
||||
Event::Text(t) => text.push_str(&t.decode().map_err(|e| GpxError::Xml(e.to_string()))?),
|
||||
Event::CData(t) => text.push_str(&String::from_utf8_lossy(&t)),
|
||||
Event::End(_) => {
|
||||
let Some(name) = path.pop() else { continue };
|
||||
let parent = path.last().map(Vec::as_slice);
|
||||
match name.as_slice() {
|
||||
b"ele" if parent == Some(b"trkpt") => {
|
||||
if let Some(p) = pt.as_mut() {
|
||||
p.ele = text.trim().parse().ok();
|
||||
}
|
||||
}
|
||||
b"time" if parent == Some(b"trkpt") => {
|
||||
if let Some(p) = pt.as_mut() {
|
||||
p.time = parse_time(text.trim());
|
||||
}
|
||||
}
|
||||
b"trkpt" => finish_point(&mut log, pt.take(), segment)?,
|
||||
_ => {}
|
||||
}
|
||||
text.clear();
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !seen_gpx {
|
||||
return Err(GpxError::NotGpx);
|
||||
}
|
||||
// stable: points logged at the same second keep their file order
|
||||
log.points.sort_by(|a, b| a.time.total_cmp(&b.time));
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
fn finish_point(log: &mut Tracklog, p: Option<Pending>, segment: u32) -> Result<(), GpxError> {
|
||||
let Some(p) = p else { return Ok(()) };
|
||||
let (Some(lat), Some(lon)) = (p.lat, p.lon) else { return Ok(()) };
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(time) = p.time else {
|
||||
log.untimed += 1;
|
||||
return Ok(());
|
||||
};
|
||||
if log.points.len() >= MAX_POINTS {
|
||||
return Err(GpxError::TooComplex);
|
||||
}
|
||||
log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation: p.ele, segment });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Tracklog {
|
||||
/// First and last point times (seconds since the epoch, UTC).
|
||||
pub fn span(&self) -> Option<(f64, f64)> {
|
||||
Some((self.points.first()?.time, self.points.last()?.time))
|
||||
}
|
||||
|
||||
/// Where the logger was at `time` (seconds since the epoch, UTC).
|
||||
///
|
||||
/// Between two points of the same segment that are at most `max_gap` seconds apart, the position
|
||||
/// is interpolated linearly in time. Otherwise the nearest point counts if it is within `max_gap`
|
||||
/// seconds; beyond that there is no position (the photo was taken while nothing was logged).
|
||||
pub fn locate(&self, time: f64, max_gap: f64) -> Option<(Gps, Match)> {
|
||||
let pts = &self.points;
|
||||
if pts.is_empty() || !time.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let i = pts.partition_point(|p| p.time < time);
|
||||
let next = pts.get(i);
|
||||
let prev = i.checked_sub(1).and_then(|j| pts.get(j));
|
||||
if let Some(n) = next
|
||||
&& n.time == time
|
||||
{
|
||||
return Some((gps(n), Match::Interpolated));
|
||||
}
|
||||
if let (Some(a), Some(b)) = (prev, next)
|
||||
&& a.segment == b.segment
|
||||
&& b.time - a.time <= max_gap
|
||||
{
|
||||
let t = (time - a.time) / (b.time - a.time);
|
||||
return Some((lerp(a, b, t), Match::Interpolated));
|
||||
}
|
||||
let near = match (prev, next) {
|
||||
(Some(a), Some(b)) => {
|
||||
if time - a.time <= b.time - time {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => return None,
|
||||
};
|
||||
((near.time - time).abs() <= max_gap).then(|| (gps(near), Match::Nearest))
|
||||
}
|
||||
}
|
||||
|
||||
fn gps(p: &TrackPoint) -> Gps {
|
||||
Gps { latitude: p.latitude, longitude: p.longitude, altitude: p.elevation }
|
||||
}
|
||||
|
||||
/// Linear interpolation, taking the short way across the ±180° meridian.
|
||||
fn lerp(a: &TrackPoint, b: &TrackPoint, t: f64) -> Gps {
|
||||
let mut dlon = b.longitude - a.longitude;
|
||||
if dlon > 180.0 {
|
||||
dlon -= 360.0;
|
||||
} else if dlon < -180.0 {
|
||||
dlon += 360.0;
|
||||
}
|
||||
let mut lon = a.longitude + dlon * t;
|
||||
if lon > 180.0 {
|
||||
lon -= 360.0;
|
||||
} else if lon < -180.0 {
|
||||
lon += 360.0;
|
||||
}
|
||||
let altitude = match (a.elevation, b.elevation) {
|
||||
(Some(x), Some(y)) => Some(x + (y - x) * t),
|
||||
(x, y) => x.or(y),
|
||||
};
|
||||
Gps { latitude: a.latitude + (b.latitude - a.latitude) * t, longitude: lon, altitude }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LOG: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="1.0" lon="1.0"><time>2026-05-01T10:00:30Z</time></wpt>
|
||||
<trk><name>Walk</name>
|
||||
<trkseg>
|
||||
<trkpt lat="46.0000" lon="7.0000"><ele>1600</ele><time>2026-05-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="46.0010" lon="7.0020"><ele>1610</ele><time>2026-05-01T10:01:00Z</time></trkpt>
|
||||
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00.500Z</time></trkpt>
|
||||
<trkpt lat="46.5" lon="7.5"/>
|
||||
</trkseg>
|
||||
<trkseg>
|
||||
<trkpt lat="47.0000" lon="8.0000"><time>2026-05-01T12:00:00Z</time></trkpt>
|
||||
<trkpt lat="47.0010" lon="8.0010"><time>2026-05-01T12:00:10+00:00</time></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>"#;
|
||||
|
||||
fn t(s: &str) -> f64 {
|
||||
parse_time(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timed_track_points() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
assert_eq!(log.tracks, 1);
|
||||
assert_eq!(log.points.len(), 5, "waypoints and the untimed point are left out");
|
||||
assert_eq!(log.untimed, 1);
|
||||
assert_eq!(log.points[0].elevation, Some(1600.0));
|
||||
assert_eq!(log.points[2].time, t("2026-05-01T10:02:00Z") + 0.5);
|
||||
assert_eq!((log.points[0].segment, log.points[2].segment, log.points[3].segment), (0, 0, 1));
|
||||
assert_eq!(log.span(), Some((t("2026-05-01T10:00:00Z"), t("2026-05-01T12:00:10Z"))));
|
||||
// 2026-05-01T10:00:00Z
|
||||
assert_eq!(log.points[0].time, 1_777_629_600.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_within_a_segment() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
let (g, m) = log.locate(t("2026-05-01T10:00:30Z"), 300.0).unwrap();
|
||||
assert_eq!(m, Match::Interpolated);
|
||||
assert!((g.latitude - 46.0005).abs() < 1e-9 && (g.longitude - 7.0010).abs() < 1e-9, "{g:?}");
|
||||
assert_eq!(g.altitude, Some(1605.0));
|
||||
// exactly on a point
|
||||
let (g, _) = log.locate(t("2026-05-01T10:01:00Z"), 300.0).unwrap();
|
||||
assert_eq!((g.latitude, g.longitude), (46.0010, 7.0020));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_interpolates_across_segments() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
// between the segments (10:02 … 12:00): only near either end
|
||||
let (g, m) = log.locate(t("2026-05-01T10:04:00Z"), 300.0).unwrap();
|
||||
assert_eq!((m, g.latitude), (Match::Nearest, 46.0020));
|
||||
let (g, m) = log.locate(t("2026-05-01T11:58:00Z"), 300.0).unwrap();
|
||||
assert_eq!((m, g.latitude), (Match::Nearest, 47.0));
|
||||
assert!(log.locate(t("2026-05-01T11:00:00Z"), 300.0).is_none(), "an hour from any point");
|
||||
// before the first and after the last point
|
||||
assert!(log.locate(t("2026-05-01T09:59:00Z"), 300.0).is_some());
|
||||
assert!(log.locate(t("2026-05-01T09:50:00Z"), 300.0).is_none());
|
||||
assert!(log.locate(t("2026-05-01T12:10:00Z"), 300.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_gap_limits_interpolation() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
// the points are 60 s apart: with a 20 s limit, only the nearest point within 20 s counts
|
||||
assert!(log.locate(t("2026-05-01T10:00:30Z"), 20.0).is_none());
|
||||
let (g, m) = log.locate(t("2026-05-01T10:00:50Z"), 20.0).unwrap();
|
||||
assert_eq!((m, g.latitude), (Match::Nearest, 46.0010));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpx_1_0_prefixes_and_zones() {
|
||||
let s = r#"<gpx:gpx version="1.0" xmlns:gpx="http://www.topografix.com/GPX/1/0"><gpx:trk><gpx:trkseg>
|
||||
<gpx:trkpt lat="-33.9" lon="151.2"><gpx:time>2026-01-02T10:00:00+11:00</gpx:time></gpx:trkpt>
|
||||
<gpx:trkpt lat="-33.8" lon="151.3"><gpx:time>2026-01-01T23:01:00</gpx:time></gpx:trkpt>
|
||||
</gpx:trkseg></gpx:trk></gpx:gpx>"#;
|
||||
let log = parse_gpx(s).unwrap();
|
||||
assert_eq!(log.points.len(), 2);
|
||||
// +11:00 is converted to UTC; no zone = UTC
|
||||
assert_eq!(log.points[1].time - log.points[0].time, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_across_the_antimeridian() {
|
||||
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 };
|
||||
let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0 };
|
||||
let g = lerp(&a, &b, 0.75);
|
||||
assert!((g.longitude - -179.5).abs() < 1e-9, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_gpx_and_bad_input() {
|
||||
assert_eq!(parse_gpx("<kml><Document/></kml>"), Err(GpxError::NotGpx));
|
||||
assert!(parse_gpx("").is_err());
|
||||
assert!(
|
||||
parse_gpx("<gpx><trk><trkseg><trkpt lat=\"x\" lon=\"1\"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>")
|
||||
.unwrap()
|
||||
.points
|
||||
.is_empty()
|
||||
);
|
||||
let deep = "<gpx>".to_string() + &"<a>".repeat(100);
|
||||
assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex));
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -6,12 +6,14 @@
|
||||
//! - [`embedded`] / [`jpeg_segments`] / [`png_chunks`] / [`webp_chunks`] — locate Exif / XMP / ICC / IPTC blocks.
|
||||
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
|
||||
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
|
||||
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
|
||||
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod container;
|
||||
mod datetime;
|
||||
mod exif;
|
||||
mod gpx;
|
||||
mod iptc;
|
||||
pub mod tags;
|
||||
mod xmp;
|
||||
@@ -19,6 +21,7 @@ mod xmp;
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
|
||||
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
|
||||
pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use tags::{TagRow, file_tag_rows, tag_rows};
|
||||
@@ -83,6 +86,12 @@ pub struct Metadata {
|
||||
// descriptive / user
|
||||
pub artist: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
/// Copyright status (`xmpRights:Marked`): `true` = copyrighted, `false` = public domain, `None` = unknown.
|
||||
pub copyright_marked: Option<bool>,
|
||||
/// Rights usage terms (`xmpRights:UsageTerms`).
|
||||
pub usage_terms: Option<String>,
|
||||
/// Copyright info URL (`xmpRights:WebStatement`).
|
||||
pub copyright_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
/// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`).
|
||||
@@ -142,6 +151,9 @@ impl Metadata {
|
||||
height,
|
||||
artist,
|
||||
copyright,
|
||||
copyright_marked,
|
||||
usage_terms,
|
||||
copyright_url,
|
||||
title,
|
||||
caption,
|
||||
alt_text,
|
||||
@@ -164,7 +176,7 @@ impl Metadata {
|
||||
/// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with
|
||||
/// `other`'s where `other` has them — the XMP-over-EXIF precedence rule.
|
||||
pub fn overlay_user_fields(&mut self, other: &Metadata) {
|
||||
overlay!(self, other, rating, label, title, caption, artist, copyright, gps);
|
||||
overlay!(self, other, rating, label, title, caption, artist, copyright, copyright_marked, usage_terms, copyright_url, gps);
|
||||
if !other.keywords.is_empty() {
|
||||
self.keywords = other.keywords.clone();
|
||||
}
|
||||
|
||||
@@ -420,6 +420,13 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
|
||||
state: first("photoshop:State"),
|
||||
country: first("photoshop:Country"),
|
||||
copyright: first("dc:rights"),
|
||||
copyright_marked: first("xmpRights:Marked").and_then(|s| match s.to_ascii_lowercase().as_str() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}),
|
||||
usage_terms: first("xmpRights:UsageTerms"),
|
||||
copyright_url: first("xmpRights:WebStatement"),
|
||||
rating: num("xmp:Rating").map(|v| v.round().clamp(-1.0, 5.0) as i8),
|
||||
label: first("xmp:Label"),
|
||||
..Default::default()
|
||||
@@ -519,6 +526,8 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
|
||||
push("photoshop:City", meta.city.clone());
|
||||
push("photoshop:State", meta.state.clone());
|
||||
push("photoshop:Country", meta.country.clone());
|
||||
push("xmpRights:Marked", meta.copyright_marked.map(|m| if m { "True" } else { "False" }.to_string()));
|
||||
push("xmpRights:WebStatement", meta.copyright_url.clone());
|
||||
if let Some(g) = meta.gps {
|
||||
push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S')));
|
||||
push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W')));
|
||||
@@ -566,6 +575,7 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
|
||||
array(&mut x, "Iptc4xmpCore:AltTextAccessibility", "Alt", &one(&meta.alt_text), true);
|
||||
array(&mut x, "Iptc4xmpCore:ExtDescrAccessibility", "Alt", &one(&meta.extended_description), true);
|
||||
array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true);
|
||||
array(&mut x, "xmpRights:UsageTerms", "Alt", &one(&meta.usage_terms), true);
|
||||
array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false);
|
||||
array(&mut x, "dc:subject", "Bag", &meta.keywords, false);
|
||||
array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false);
|
||||
@@ -620,6 +630,9 @@ mod tests {
|
||||
height: Some(6000),
|
||||
artist: Some("Ann \"Photo\" Lee".into()),
|
||||
copyright: Some("© 2022".into()),
|
||||
copyright_marked: Some(true),
|
||||
usage_terms: Some("Editorial use only; no <resale>".into()),
|
||||
copyright_url: Some("https://example.com/licence?a=1&b=2".into()),
|
||||
title: Some("Tïtle".into()),
|
||||
caption: Some("Line one\nline two".into()),
|
||||
alt_text: Some("A tree by the sea".into()),
|
||||
@@ -683,6 +696,32 @@ mod tests {
|
||||
assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]);
|
||||
}
|
||||
|
||||
/// Copyright status, usage terms and info URL (XMP Rights Management schema): written as
|
||||
/// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI).
|
||||
#[test]
|
||||
fn rights_management_fields() {
|
||||
let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() };
|
||||
let x = write_xmp(&m, None);
|
||||
assert!(x.contains("<xmpRights:Marked>False</xmpRights:Marked>"), "{x}");
|
||||
assert!(x.contains("<xmpRights:UsageTerms>\n <rdf:Alt>\n <rdf:li xml:lang=\"x-default\">CC0</rdf:li>"), "{x}");
|
||||
assert!(!x.contains("WebStatement"), "unset fields are left out");
|
||||
assert_eq!(parse_xmp(&x).unwrap().metadata, m);
|
||||
// attribute form, other prefixes; anything but True / False is "unknown"
|
||||
let read = |marked: &str| {
|
||||
let x = format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:r="http://ns.adobe.com/xap/1.0/rights/" r:Marked="{marked}" r:WebStatement="https://example.org/c"/>
|
||||
</rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
parse_xmp(&x).unwrap().metadata
|
||||
};
|
||||
assert_eq!(read("True").copyright_marked, Some(true));
|
||||
assert_eq!(read("false").copyright_marked, Some(false));
|
||||
assert_eq!(read("").copyright_marked, None);
|
||||
assert_eq!(read("maybe").copyright_marked, None);
|
||||
assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs() {
|
||||
assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err());
|
||||
|
||||
@@ -211,19 +211,22 @@ impl Frame {
|
||||
Some((nw, nh)) => std::borrow::Cow::Owned(resize(o, nw, nh, Filter::Mitchell)),
|
||||
None => std::borrow::Cow::Borrowed(o),
|
||||
};
|
||||
match plan.mode {
|
||||
SampleMode::Copy => base.into_owned(),
|
||||
SampleMode::Warp(o2t) => sample_warped(&base, plan.sx, plan.sy, self.warp.as_ref().expect("warp"), o2t, w, h),
|
||||
SampleMode::Affine(xf) => {
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
*px = base.sample_bilinear(p.x as f32, p.y as f32);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
let affine = |xf: Affine| {
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
*px = base.sample_bilinear(p.x as f32, p.y as f32);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
match (plan.mode, self.warp.as_ref()) {
|
||||
(SampleMode::Copy, _) => base.into_owned(),
|
||||
(SampleMode::Warp(o2t), Some(warp)) => sample_warped(&base, plan.sx, plan.sy, warp, o2t, w, h),
|
||||
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
|
||||
(SampleMode::Warp(o2t), None) => affine(Affine::scale(plan.sx, plan.sy) * o2t),
|
||||
(SampleMode::Affine(xf), _) => affine(xf),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! [`render_cached`] each stage's output is reused while its inputs are unchanged ([`StageCache`]):
|
||||
//! dragging a tone, colour or exposure slider re-runs only the per-pixel stage.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod auto;
|
||||
pub mod colorops;
|
||||
|
||||
@@ -217,12 +217,12 @@ impl DeepImage {
|
||||
let trc = self.space.trc();
|
||||
match &self.samples {
|
||||
DeepSamples::U16(v) => {
|
||||
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
|
||||
for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) {
|
||||
*p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba();
|
||||
}
|
||||
}
|
||||
DeepSamples::F32(v) => {
|
||||
for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) {
|
||||
for (p, c) in out.data.iter_mut().zip(v.as_chunks::<3>().0) {
|
||||
*p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba();
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user