mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-16 12:16:59 -04:00
Compare commits
400
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16dbb90499 | ||
|
|
73c61aae44 | ||
|
|
a4b53b28cc | ||
|
|
180d400cdd | ||
|
|
5fe332c7b4 | ||
|
|
195fb9698b | ||
|
|
a5989d6c0d | ||
|
|
48b9a5e21f | ||
|
|
418f6255be | ||
|
|
9085eb4b23 | ||
|
|
694ce5b42b | ||
|
|
aa0f9a5666 | ||
|
|
5e1213c963 | ||
|
|
ea7f799607 | ||
|
|
71a43d55ce | ||
|
|
89b6a34595 | ||
|
|
cc7179c560 | ||
|
|
4fe0466942 | ||
|
|
e783f705dc | ||
|
|
35c4290d14 | ||
|
|
0a68a21e03 | ||
|
|
61d96fc037 | ||
|
|
93bd81a480 | ||
|
|
1dd41b1872 | ||
|
|
4d0e11651a | ||
|
|
c8f1ecd4d3 | ||
|
|
deb7831083 | ||
|
|
b54a2e9793 | ||
|
|
992e67188b | ||
|
|
b18ce72b61 | ||
|
|
1c65549d10 | ||
|
|
9b7d2e6ae2 | ||
|
|
07f73c44da | ||
|
|
b4d5b47617 | ||
|
|
bcfda31678 | ||
|
|
83a89ff271 | ||
|
|
3489c45cf9 | ||
|
|
f12cf65ab7 | ||
|
|
ce607d5a49 | ||
|
|
aaf7f2cc47 | ||
|
|
ddc347cc55 | ||
|
|
528a464740 | ||
|
|
3ad5eadab2 | ||
|
|
aa4e3ce822 | ||
|
|
1ee751ebdf | ||
|
|
d71e7d11c1 | ||
|
|
93f8ee7c0d | ||
|
|
ce3fad5a77 | ||
|
|
f478b000de | ||
|
|
cd59698798 | ||
|
|
e067581ff3 | ||
|
|
3f9b3b20cb | ||
|
|
3b848a2093 | ||
|
|
13c32ef1bb | ||
|
|
05dc7374d8 | ||
|
|
9f8a17277e | ||
|
|
0a2b2afb2e | ||
|
|
2ddc147f60 | ||
|
|
7942df9dd4 | ||
|
|
642662d90f | ||
|
|
f62be0925c | ||
|
|
2294329b0c | ||
|
|
7627b2b2b8 | ||
|
|
5ece09aa6c | ||
|
|
2fd0530beb | ||
|
|
8bcbcd918b | ||
|
|
6b00656df8 | ||
|
|
2341132b35 | ||
|
|
c6b2686ba0 | ||
|
|
02bdad38d2 | ||
|
|
6d124d701b | ||
|
|
d823d155b2 | ||
|
|
f0493fa11b | ||
|
|
836bc449f0 | ||
|
|
ac1eaf1d68 | ||
|
|
03b6c8a95d | ||
|
|
cd5f16d5ac | ||
|
|
840733f760 | ||
|
|
7505017ea3 | ||
|
|
481272c1a3 | ||
|
|
853a92e2de | ||
|
|
271bc41842 | ||
|
|
4a07254e06 | ||
|
|
f753778f45 | ||
|
|
4c088c3284 | ||
|
|
b43b7169aa | ||
|
|
307843b2ad | ||
|
|
e1860067a6 | ||
|
|
911d7df321 | ||
|
|
57487875b5 | ||
|
|
f157654628 | ||
|
|
27d1e9b04d | ||
|
|
337f02b4dc | ||
|
|
3624a50423 | ||
|
|
cef6f2d4b8 | ||
|
|
d5cba8b3dc | ||
|
|
d60fd328db | ||
|
|
65dbba9ecd | ||
|
|
b62b51378e | ||
|
|
e9d36451e6 | ||
|
|
2783e5de9b | ||
|
|
3946688f49 | ||
|
|
d77d1a4650 | ||
|
|
82da463f56 | ||
|
|
935fabeb18 | ||
|
|
f865aa28c3 | ||
|
|
8e26ba6c94 | ||
|
|
51ce34d78c | ||
|
|
4a91a1c218 | ||
|
|
7d29dfac58 | ||
|
|
df914dca41 | ||
|
|
54321e6175 | ||
|
|
d5826e2386 | ||
|
|
c38bf96ef9 | ||
|
|
14e0d12813 | ||
|
|
fb47e4a185 | ||
|
|
6f5e1f876e | ||
|
|
e7cd00ef56 | ||
|
|
5bf455c456 | ||
|
|
0d13a7d5d7 | ||
|
|
e82b5089f7 | ||
|
|
5e9a1b6ec7 | ||
|
|
e935ad75e2 | ||
|
|
094686a2f6 | ||
|
|
0d8e3e3913 | ||
|
|
c10ca4e958 | ||
|
|
26df1c5cdf | ||
|
|
b73c0599fa | ||
|
|
6477d031fc | ||
|
|
b3d392b499 | ||
|
|
ae9b09d3c2 | ||
|
|
c94ebae470 | ||
|
|
4ee6a4888b | ||
|
|
1462d96360 | ||
|
|
edc3661680 | ||
|
|
68bacba288 | ||
|
|
8a640dc70b | ||
|
|
478f20054b | ||
|
|
d8f10a1ef7 | ||
|
|
643bc7f909 | ||
|
|
8bb084074c | ||
|
|
8155c118cd | ||
|
|
68c924047d | ||
|
|
0a565d308a | ||
|
|
2397927681 | ||
|
|
ece9bd83e4 | ||
|
|
7693cbf3ae | ||
|
|
2e877ad476 | ||
|
|
2d28ea81bd | ||
|
|
b709180587 | ||
|
|
056b0f7a28 | ||
|
|
1d70cf6358 | ||
|
|
2f0931d3ca | ||
|
|
a1035f1372 | ||
|
|
d5b8d52e1d | ||
|
|
6568a7ae16 | ||
|
|
d752076339 | ||
|
|
702f90bfc9 | ||
|
|
fa3dfbbc4a | ||
|
|
84d6849cf3 | ||
|
|
ed1d641f22 | ||
|
|
692ab7f7c2 | ||
|
|
32253d862a | ||
|
|
4e78514948 | ||
|
|
bac147a460 | ||
|
|
2181c69162 | ||
|
|
963d4f327a | ||
|
|
97f8b8c87c | ||
|
|
8df468bfc6 | ||
|
|
8ca6a835ec | ||
|
|
251397694e | ||
|
|
94808da369 | ||
|
|
edfe7c6351 | ||
|
|
95d519ae64 | ||
|
|
1c473ed64c | ||
|
|
cb8aa0b017 | ||
|
|
d3c024e4df | ||
|
|
74a0b5796a | ||
|
|
4764e85f37 | ||
|
|
0ab59399c8 | ||
|
|
6bbceb2fa8 | ||
|
|
d6583cebf8 | ||
|
|
cc9deaf64b | ||
|
|
76fd9b9ae8 | ||
|
|
62b1ba04d0 | ||
|
|
e4873dd480 | ||
|
|
9b4047b1d4 | ||
|
|
ac19978d37 | ||
|
|
6eb04f9a1c | ||
|
|
a681a3c667 | ||
|
|
c7e259b3ce | ||
|
|
bcbcf3b968 | ||
|
|
e6ef0aa368 | ||
|
|
695eb6acc5 | ||
|
|
5475691ea4 | ||
|
|
5e430d057f | ||
|
|
b089188ee5 | ||
|
|
9e0f00c557 | ||
|
|
a0b1da61aa | ||
|
|
e9892a6f9d | ||
|
|
b297049e52 | ||
|
|
54c4549fe9 | ||
|
|
14e2a480f0 | ||
|
|
33f03ba0f7 | ||
|
|
18bd8fd881 | ||
|
|
a39567306b | ||
|
|
14b111f6f5 | ||
|
|
d3d55e8327 | ||
|
|
ecf1236b7c | ||
|
|
f28b9c46d3 | ||
|
|
6aabe43386 | ||
|
|
b9b6bf57de | ||
|
|
64b3361125 | ||
|
|
44684b7950 | ||
|
|
61c3042efe | ||
|
|
2fc2419fa1 | ||
|
|
5cec4d0720 | ||
|
|
b3860eb734 | ||
|
|
931b0dd067 | ||
|
|
fa7cd628e5 | ||
|
|
25aff046f3 | ||
|
|
9f84c3b60d | ||
|
|
8e808e8d92 | ||
|
|
690eefb088 | ||
|
|
536b4fffec | ||
|
|
b01902bbae | ||
|
|
dca358d435 | ||
|
|
1543fef701 | ||
|
|
ac19325c57 | ||
|
|
3ba3120637 | ||
|
|
b56f052a59 | ||
|
|
5388d20088 | ||
|
|
b2a5997e55 | ||
|
|
43799cc903 | ||
|
|
a6369c4009 | ||
|
|
6435019603 | ||
|
|
99e9f25796 | ||
|
|
179461aee3 | ||
|
|
3e5439cc94 | ||
|
|
c9e12bd9ff | ||
|
|
cd75e11841 | ||
|
|
5fddd79583 | ||
|
|
035c378758 | ||
|
|
9fe9beffbc | ||
|
|
e325de9db4 | ||
|
|
751d25a7f7 | ||
|
|
6a122dc684 | ||
|
|
ae029e0851 | ||
|
|
f2a9888f75 | ||
|
|
765e94e0a1 | ||
|
|
1f1374fd46 | ||
|
|
ade1a86a03 | ||
|
|
79e0e98321 | ||
|
|
a10bc04b8b | ||
|
|
c8f676252f | ||
|
|
58822f6229 | ||
|
|
4c1dfc5b0f | ||
|
|
77d8558fc4 | ||
|
|
0fa8070647 | ||
|
|
1c70b24c74 | ||
|
|
e1b29a3fd4 | ||
|
|
f47fb86077 | ||
|
|
713d81d64b | ||
|
|
cd3a942376 | ||
|
|
dead4367b0 | ||
|
|
a99c084805 | ||
|
|
60639aa677 | ||
|
|
f0d1ea9c4e | ||
|
|
c07035c444 | ||
|
|
3480c99b3e | ||
|
|
5eee65edac | ||
|
|
d9006c96f6 | ||
|
|
6a82142d69 | ||
|
|
1926c9029f | ||
|
|
c11764a12a | ||
|
|
07d7d5d498 | ||
|
|
8c539426eb | ||
|
|
d41de88c05 | ||
|
|
dd502bac9a | ||
|
|
13e803ab07 | ||
|
|
f0862221c1 | ||
|
|
ef3e50bb2d | ||
|
|
7aaeef2676 | ||
|
|
ecd6618f6f | ||
|
|
35ade2dfbf | ||
|
|
d7037d117f | ||
|
|
acbd08eb73 | ||
|
|
e343b7aa6d | ||
|
|
73a10f5b6d | ||
|
|
978132fdca | ||
|
|
a9812d0cb4 | ||
|
|
fa28792363 | ||
|
|
c56fa85a98 | ||
|
|
fbfc1b0296 | ||
|
|
e79027f728 | ||
|
|
6c033ea5dc | ||
|
|
af269b1104 | ||
|
|
8a4fec438a | ||
|
|
701f78ea2e | ||
|
|
a940fae193 | ||
|
|
a234a20876 | ||
|
|
edb3cbfd8a | ||
|
|
03a3bbd561 | ||
|
|
031537ec02 | ||
|
|
20ba473e85 | ||
|
|
7b6b25e6ab | ||
|
|
fbfa87d614 | ||
|
|
994c820bec | ||
|
|
fb725ce33f | ||
|
|
77ebac813a | ||
|
|
7ccff3b96e | ||
|
|
139e9633cf | ||
|
|
cf085041ed | ||
|
|
1dc86d752c | ||
|
|
372080cd5a | ||
|
|
3c50fd58e6 | ||
|
|
63bcaa147f | ||
|
|
33accc3892 | ||
|
|
208aa023b0 | ||
|
|
a4716d9e4f | ||
|
|
978312b5cc | ||
|
|
25ef65c81e | ||
|
|
f1c023ed3d | ||
|
|
40b7b9b385 | ||
|
|
92a2e721e2 | ||
|
|
9fc2e69328 | ||
|
|
2d8fc776b2 | ||
|
|
1f45462529 | ||
|
|
537789cf1d | ||
|
|
6fd740ac2d | ||
|
|
6d9b1a64bf | ||
|
|
81733ffd81 | ||
|
|
17aafcd5bf | ||
|
|
3af5280ea6 | ||
|
|
90a4088f10 | ||
|
|
559d340d1c | ||
|
|
9ae3b365a1 | ||
|
|
96085366f0 | ||
|
|
2a837254b0 | ||
|
|
b05e6a6b86 | ||
|
|
a73382a5e2 | ||
|
|
fa7ee6ee9d | ||
|
|
594362d717 | ||
|
|
536d6b88ac | ||
|
|
31c31b726b | ||
|
|
67dc806ca9 | ||
|
|
89fa4e4b59 | ||
|
|
929202b95f | ||
|
|
b3fcb77922 | ||
|
|
e4c36749ef | ||
|
|
2df78c9e58 | ||
|
|
4ddbb5a398 | ||
|
|
090553d14d | ||
|
|
12498b3acf | ||
|
|
8ee7519a5f | ||
|
|
6797c6b483 | ||
|
|
aa7e0bbc34 | ||
|
|
465601b76c | ||
|
|
b7e39afe79 | ||
|
|
ed737afda0 | ||
|
|
b538b23d40 | ||
|
|
83e6108346 | ||
|
|
b453f587be | ||
|
|
6cde09f38f | ||
|
|
ba45333986 | ||
|
|
1397c4b42a | ||
|
|
fdccb413cd | ||
|
|
73098bf5a3 | ||
|
|
2c3d7a743f | ||
|
|
87be53279b | ||
|
|
482dcacd37 | ||
|
|
8695c3391f | ||
|
|
a1d7c488f8 | ||
|
|
103089535a | ||
|
|
3c7f450137 | ||
|
|
6fc33e1ee1 | ||
|
|
b28ce8ef15 | ||
|
|
b7222213c9 | ||
|
|
6c79be9130 | ||
|
|
2da4b36b45 | ||
|
|
530320a6bd | ||
|
|
a61a52ed8b | ||
|
|
0a6f8d3db8 | ||
|
|
48c61e80c1 | ||
|
|
0e53cfc240 | ||
|
|
dd1a857d49 | ||
|
|
15e308c3eb | ||
|
|
8760dc4a41 | ||
|
|
76b5377868 | ||
|
|
2801199bea | ||
|
|
8c19c9f60a | ||
|
|
e4f92df78c | ||
|
|
c41b1e311d | ||
|
|
a77b0cc472 | ||
|
|
9e2f383166 | ||
|
|
a90cdcd2f3 | ||
|
|
f4761b459c | ||
|
|
8e61ad6b13 | ||
|
|
7707379812 | ||
|
|
c8dff74948 |
No files matched your search
@@ -58,6 +58,11 @@ jobs:
|
||||
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
|
||||
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
|
||||
done
|
||||
# The completion page the Publish events point at must actually exist: a Publish alone
|
||||
# does not pull the dialog fragment into the MSI, and without the DialogRef the
|
||||
# installer finishes silently (issues #790, #892).
|
||||
grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \
|
||||
|| { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
|
||||
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
|
||||
@@ -16,7 +16,8 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
|
||||
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
|
||||
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
|
||||
path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
path the tracker cites still exists and that `docs/gaps.md` lists each gap once, and `cargo xtask parity --write`
|
||||
refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
Generated
+33
-33
@@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3097,7 +3097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -3114,7 +3114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -3130,7 +3130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
@@ -3159,7 +3159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3168,7 +3168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3185,7 +3185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3200,7 +3200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3212,7 +3212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
@@ -3252,7 +3252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
@@ -3266,7 +3266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3277,7 +3277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
@@ -3285,7 +3285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3293,7 +3293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -3311,7 +3311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
@@ -3319,14 +3319,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-inpaint"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-lensdb"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
@@ -3335,7 +3335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3346,7 +3346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3362,7 +3362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3374,7 +3374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3392,7 +3392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3404,7 +3404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3416,7 +3416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3438,7 +3438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3449,7 +3449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3462,11 +3462,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3475,7 +3475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3502,7 +3502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -314,7 +314,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
- **on-screen HDR display, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
|
||||
|
||||
@@ -337,7 +337,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
|
||||
@@ -370,6 +370,31 @@ per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged.
|
||||
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
|
||||
override these defaults. Ordinary development commands keep their own settings.
|
||||
|
||||
**Release builds on memory-constrained machines:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
|
||||
reports Arch/AUR release-link OOM kills on a 16 GB machine when the desktop and CLI build concurrently
|
||||
with full debug information, thin LTO and one codegen unit. The CI defaults above do not apply to
|
||||
ordinary `cargo build --release`. Start with one job; two may be suitable if enough memory is available:
|
||||
|
||||
```sh
|
||||
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
|
||||
# Alternatively: CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
|
||||
```
|
||||
|
||||
If a packaging environment enables full debug information or memory-heavy LTO settings, use
|
||||
command-local overrides for a lower-memory source build:
|
||||
|
||||
```sh
|
||||
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
|
||||
```
|
||||
|
||||
These POSIX-shell assignments affect only this command, not global Cargo configuration or the
|
||||
repository's release profiles. Disabling LTO may change performance and binary size; disabling debug
|
||||
information reduces debugging detail. This is build guidance, not a guarantee that any machine has
|
||||
enough memory. Downstream packagers should serialize large binary builds and avoid full debug
|
||||
information combined with LTO and one codegen unit on low-memory builders. A PKGBUILD that exports
|
||||
its own profile settings can override inherited variables; adjust the packaging recipe locally or
|
||||
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
|
||||
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
@@ -385,7 +410,9 @@ Language**; the choice applies immediately and persists. See [docs/localization.
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library.
|
||||
**Portable mode:** a file named `portable.txt` (or `LightCraft.portable`) next to the executable moves the settings
|
||||
folder to `LightCraftData` beside it. A
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
|
||||
Folder** shows it in the file manager). Each start moves the
|
||||
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
|
||||
@@ -409,6 +436,9 @@ nix build github:storytold/lightcraft # → ./result/bin/{light
|
||||
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
|
||||
```
|
||||
|
||||
On NixOS, run a `cargo` build from `nix develop`: outside it the app can't find `libvulkan.so.1`, finds no GPU and
|
||||
renders on the CPU, much more slowly (it says so once at start-up). `nix run` and `nix build` set the path themselves.
|
||||
|
||||
In a flake configuration (NixOS, home-manager, nix-darwin):
|
||||
|
||||
```nix
|
||||
|
||||
@@ -359,6 +359,30 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一
|
||||
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
|
||||
日常开发命令保持它们各自的设置。
|
||||
|
||||
**内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
|
||||
报告在 16 GB 的 Arch/AUR 机器上,同时构建桌面应用和 CLI,并启用完整调试信息、thin LTO
|
||||
和单个代码生成单元时,链接过程被 OOM 终止。上述 CI 默认设置不适用于普通的
|
||||
`cargo build --release`。建议先使用一个构建任务;内存充足时可以尝试两个:
|
||||
|
||||
```sh
|
||||
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
|
||||
# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
|
||||
```
|
||||
|
||||
如果打包环境启用了完整调试信息或占用较多内存的 LTO 设置,可以使用仅对本条命令生效的
|
||||
覆盖设置,降低源码构建的内存需求:
|
||||
|
||||
```sh
|
||||
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
|
||||
```
|
||||
|
||||
这些 POSIX shell 赋值只影响本条命令,不修改全局 Cargo 配置或仓库的 release profile。
|
||||
关闭 LTO 可能改变性能和二进制大小;关闭调试信息会减少可用的调试细节。这是构建建议,
|
||||
并不保证任何机器都有足够内存。下游打包者应串行构建大型二进制文件,并避免在低内存
|
||||
构建机器上同时使用完整调试信息、LTO 和单个代码生成单元。PKGBUILD 自己导出的 profile
|
||||
设置可以覆盖继承的环境变量;应在本地调整打包配方或联系维护者,而不要假定给
|
||||
`makepkg` 添加环境变量前缀就能优先生效。
|
||||
|
||||
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
|
||||
|
||||
```sh
|
||||
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
|
||||
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
|
||||
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
@@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full
|
||||
size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged.
|
||||
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
|
||||
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
|
||||
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
|
||||
@@ -161,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
|
||||
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
|
||||
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
|
||||
|
||||
@@ -303,10 +303,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--connect" => {
|
||||
// Optional address argument.
|
||||
match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) {
|
||||
// Optional address argument. A bare port (all digits) is taken as a port on the
|
||||
// loopback default — matches the help's `--connect [ADDR]` hint alongside the
|
||||
// `--control 7980` example. A non-numeric, non-`--`-prefixed token (e.g. a
|
||||
// command id like `ui.inspect`) leaves the address at its default so the
|
||||
// command is still consumed next. (#1178)
|
||||
match args.get(i + 1).filter(|a| a.contains(':') || a.chars().all(|c| c.is_ascii_digit())) {
|
||||
Some(a) => {
|
||||
connect = Some(a.clone());
|
||||
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
|
||||
i += 1;
|
||||
}
|
||||
None => connect = Some(DEFAULT_ADDR.to_string()),
|
||||
@@ -525,9 +529,9 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
"--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) {
|
||||
"--connect" => match args.get(i + 1).filter(|a| !a.contains('=') && (a.contains(':') || a.chars().all(|c| c.is_ascii_digit()))) {
|
||||
Some(a) => {
|
||||
connect = Some(a.clone());
|
||||
connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") });
|
||||
i += 1;
|
||||
}
|
||||
None => connect = Some(DEFAULT_ADDR.to_string()),
|
||||
|
||||
@@ -476,6 +476,28 @@ fn connect_failure_hint_names_the_attempted_port() {
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
}
|
||||
|
||||
/// Issue #1178: `--connect 7980 ui.inspect` (after `lightcraft --control 7980`) was leaving
|
||||
/// `7980` unconsumed so it parsed as a command id and produced "unknown command `7980`".
|
||||
/// A bare port is now taken as `127.0.0.1:<port>`; the same address used with `:port` still
|
||||
/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through.
|
||||
#[test]
|
||||
fn connect_bare_port_is_treated_as_loopback_port() {
|
||||
// Bare port: the failure hint names that port.
|
||||
let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}");
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
// `<host>:<port>` still works (issue #167 case).
|
||||
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "{err}");
|
||||
// `--connect=<bare-port>` already worked (the `a.starts_with("--connect=")` arm); spot-check
|
||||
// the hint matches its port too.
|
||||
let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "{err}");
|
||||
}
|
||||
|
||||
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
|
||||
/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
|
||||
#[test]
|
||||
|
||||
@@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
reveal: None,
|
||||
log_file: None,
|
||||
open_with: None,
|
||||
open_all_with: None,
|
||||
check_editor: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -64,7 +64,7 @@ impl WireJob {
|
||||
/// `view`: the slot name, for jobs that keep a stage cache.
|
||||
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! The ICC profile the desktop assigns to the display (Linux, through colord).
|
||||
//!
|
||||
//! GNOME, KDE and most other desktops keep each monitor's profile in colord
|
||||
//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a
|
||||
//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile
|
||||
//! is chosen in Settings ▸ Display, previews are shown through that profile
|
||||
//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never
|
||||
//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB.
|
||||
//!
|
||||
//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a
|
||||
//! window is on), so the profile is used only when every display that has one has the same: a
|
||||
//! mixed set stays sRGB rather than guess.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often colord is asked again (a profile change in the desktop's settings shows within this).
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
/// A wait before trying again when colord isn't there (not installed, no system bus).
|
||||
const RETRY: Duration = Duration::from_secs(30);
|
||||
/// The largest profile file read (real display profiles are a few KiB to a few MiB).
|
||||
const MAX_PROFILE: u64 = 16 << 20;
|
||||
|
||||
const SERVICE: &str = "org.freedesktop.ColorManager";
|
||||
const DEVICE: &str = "org.freedesktop.ColorManager.Device";
|
||||
const PROFILE: &str = "org.freedesktop.ColorManager.Profile";
|
||||
|
||||
/// Start the background reader and return the service the UI polls (`Services::screen_profile`).
|
||||
pub fn service() -> Box<dyn FnMut() -> Option<Vec<u8>>> {
|
||||
let shared: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
|
||||
let writer = Arc::clone(&shared);
|
||||
let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || {
|
||||
loop {
|
||||
let wait = match connect() {
|
||||
Some(conn) => {
|
||||
let icc = read(&conn);
|
||||
*writer.lock().unwrap_or_else(PoisonError::into_inner) = icc;
|
||||
POLL
|
||||
}
|
||||
None => RETRY,
|
||||
};
|
||||
std::thread::sleep(wait);
|
||||
// the app is gone: nobody reads the profile any more
|
||||
if Arc::strong_count(&writer) == 1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("screen profile: no colord reader: {e}");
|
||||
}
|
||||
Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone())
|
||||
}
|
||||
|
||||
fn connect() -> Option<zbus::blocking::Connection> {
|
||||
zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok()
|
||||
}
|
||||
|
||||
/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays).
|
||||
fn read(conn: &zbus::blocking::Connection) -> Option<Vec<u8>> {
|
||||
let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?;
|
||||
let devices: Vec<zbus::zvariant::OwnedObjectPath> = manager.call("GetDevicesByKind", &("display",)).ok()?;
|
||||
let mut files = Vec::new();
|
||||
for device in devices.iter().take(64) {
|
||||
let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue };
|
||||
let profiles: Vec<zbus::zvariant::OwnedObjectPath> = proxy.get_property("Profiles").unwrap_or_default();
|
||||
// colord lists a device's profiles default first
|
||||
let Some(first) = profiles.first() else { continue };
|
||||
let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue };
|
||||
if let Ok(file) = profile.get_property::<String>("Filename") {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
let file = pick(&files)?;
|
||||
load(std::path::Path::new(file))
|
||||
}
|
||||
|
||||
/// The profile to use for these displays' profile files: the one they share, `None` with none or
|
||||
/// with different ones (empty names are ignored).
|
||||
fn pick(files: &[String]) -> Option<&str> {
|
||||
let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty());
|
||||
let first = named.next()?;
|
||||
named.all(|f| f == first).then_some(first)
|
||||
}
|
||||
|
||||
/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large.
|
||||
fn load(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?;
|
||||
(!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_shared_profile_is_used_and_a_mixed_set_is_not() {
|
||||
let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(pick(&s(&[])), None);
|
||||
assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc"));
|
||||
assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc"));
|
||||
assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known");
|
||||
assert_eq!(pick(&s(&[""])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_files_are_read_with_a_size_cap() {
|
||||
let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let small = dir.join("small.icc");
|
||||
std::fs::write(&small, b"acsp").unwrap();
|
||||
assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..]));
|
||||
let empty = dir.join("empty.icc");
|
||||
std::fs::write(&empty, b"").unwrap();
|
||||
assert_eq!(load(&empty), None);
|
||||
assert_eq!(load(&dir.join("missing.icc")), None);
|
||||
let big = dir.join("big.icc");
|
||||
std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap();
|
||||
assert_eq!(load(&big), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -55,16 +55,65 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a request waits for its reply.
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// A request the app has not picked up while it drew no frame for this long is given up: the
|
||||
/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no
|
||||
/// frame callbacks, so the app's loop is paused: issue #718).
|
||||
const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The reply for a request given up because the app drew no frames.
|
||||
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
|
||||
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let state = Arc::clone(&req.state);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT))
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for a request's reply. While the app has not picked the request up and `frames` (its
|
||||
/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late)
|
||||
/// and the reply says why; a request the app is running gets the whole `timeout`.
|
||||
fn await_reply(
|
||||
rrx: &Receiver<Value>,
|
||||
state: &std::sync::atomic::AtomicU8,
|
||||
frames: impl Fn() -> u64,
|
||||
wake: impl Fn(),
|
||||
no_frames: Duration,
|
||||
timeout: Duration,
|
||||
) -> Value {
|
||||
let start = std::time::Instant::now();
|
||||
let slice = (no_frames / 10).max(Duration::from_millis(1));
|
||||
let (mut last, mut since) = (frames(), std::time::Instant::now());
|
||||
loop {
|
||||
match rrx.recv_timeout(slice) {
|
||||
Ok(v) => return v,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
let now = frames();
|
||||
if now != last {
|
||||
(last, since) = (now, std::time::Instant::now());
|
||||
}
|
||||
let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN;
|
||||
if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) {
|
||||
return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR});
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
// not run yet: make sure it never is; already running: it may still finish
|
||||
ControlRequest::abandon(state);
|
||||
return json!({"ok": false, "error": "timeout"});
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
@@ -160,6 +209,51 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given
|
||||
/// up with a reason, quickly, and the app then skips it instead of running it late.
|
||||
#[test]
|
||||
fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() {
|
||||
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10));
|
||||
assert!(t0.elapsed() < Duration::from_secs(2));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}");
|
||||
assert!(!req.take(), "the app does not run it once it shows up");
|
||||
}
|
||||
|
||||
/// A request the app is running is not given up because frames stopped (a long command
|
||||
/// blocks the frame loop), and its reply arrives.
|
||||
#[test]
|
||||
fn a_request_being_run_waits_for_its_reply() {
|
||||
let (req, rrx) = ControlRequest::new("export.run", json!({}));
|
||||
assert!(req.take());
|
||||
let reply = req.reply.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let _ = reply.send(json!({"ok": true, "result": 1}));
|
||||
});
|
||||
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
/// Frames still coming: the request just waits, then is answered.
|
||||
#[test]
|
||||
fn a_drawing_app_is_waited_for() {
|
||||
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
|
||||
let n = std::sync::atomic::AtomicU64::new(0);
|
||||
let reply = req.reply.clone();
|
||||
let state = Arc::clone(&req.state);
|
||||
let worker = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
let _ = reply.send(json!({"ok": true, "result": 2}));
|
||||
});
|
||||
let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
|
||||
@@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> {
|
||||
c.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection:
|
||||
/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is
|
||||
/// still starting when it is started once per file. The system default opens them one by one
|
||||
/// (Explorer and `xdg-open` take one file).
|
||||
pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> {
|
||||
match all_command(launcher(app)?, paths) {
|
||||
Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()),
|
||||
None => paths.iter().try_for_each(|p| open_with(p, app)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one command that opens all of `paths` in `launcher` (none for the system default).
|
||||
fn all_command(launcher: Launcher, paths: &[String]) -> Option<Command> {
|
||||
match launcher {
|
||||
Launcher::Default => None,
|
||||
Launcher::MacApp(app) => {
|
||||
let mut c = Command::new("open");
|
||||
c.args(["-a", &app]).args(paths);
|
||||
Some(c)
|
||||
}
|
||||
Launcher::Program(p) => {
|
||||
let mut c = Command::new(p);
|
||||
c.args(paths);
|
||||
Some(c)
|
||||
}
|
||||
Launcher::Flatpak => {
|
||||
let mut c = Command::new("flatpak");
|
||||
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@");
|
||||
Some(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: open `path` in its default application through Explorer, given the path as its one
|
||||
/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own
|
||||
/// syntax (running what follows an `&` as a command).
|
||||
@@ -172,8 +205,10 @@ fn desktop_exec(entry: &str) -> Option<PathBuf> {
|
||||
Some(rest) => rest.split('"').next()?,
|
||||
None => exec.split_whitespace().next()?,
|
||||
};
|
||||
// a bare name (`photocraft %F`) was already looked for on the PATH
|
||||
Some(PathBuf::from(program)).filter(|p| p.is_absolute())
|
||||
// a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a
|
||||
// freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on
|
||||
// (`Path::is_absolute` wants a drive letter on Windows, where the tests run too)
|
||||
program.starts_with('/').then(|| PathBuf::from(program))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -191,6 +226,25 @@ mod tests {
|
||||
assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]);
|
||||
}
|
||||
|
||||
/// Open Original with several photos: one start of the editor with every file, which a
|
||||
/// single-window editor (DxO PhotoLab) needs to get them all.
|
||||
#[test]
|
||||
fn several_files_go_to_one_start_of_the_editor() {
|
||||
let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()];
|
||||
let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
|
||||
let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap();
|
||||
assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe");
|
||||
assert_eq!(args(&c), paths);
|
||||
let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap();
|
||||
assert_eq!(
|
||||
(c.get_program().to_string_lossy().into_owned(), args(&c)),
|
||||
("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()])
|
||||
);
|
||||
let c = all_command(Launcher::Flatpak, &paths).unwrap();
|
||||
assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]);
|
||||
assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_lookup_prefers_the_recorded_install_folder() {
|
||||
let env = |k: &str| match k {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR
|
||||
//! white, and with which surface format. Pure decisions, unit-tested here; the window always
|
||||
//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong.
|
||||
//!
|
||||
//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer,
|
||||
//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol),
|
||||
//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal
|
||||
//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the
|
||||
//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the
|
||||
//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same
|
||||
//! decision turns HDR on.
|
||||
|
||||
use eframe::wgpu::TextureFormat;
|
||||
|
||||
/// Windowing platform, as far as HDR output is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
MacOs,
|
||||
Windows,
|
||||
Wayland,
|
||||
X11,
|
||||
Web,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
/// The platform this build runs on (`wayland` from the session environment on Linux / BSD).
|
||||
pub fn current() -> Self {
|
||||
if cfg!(target_os = "macos") {
|
||||
Self::MacOs
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Self::Windows
|
||||
} else if cfg!(target_arch = "wasm32") {
|
||||
Self::Web
|
||||
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
Self::Wayland
|
||||
} else if std::env::var_os("DISPLAY").is_some() {
|
||||
Self::X11
|
||||
} else {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the platform has an HDR compositing path wgpu can reach at all.
|
||||
fn has_hdr_path(self) -> bool {
|
||||
matches!(self, Self::MacOs | Self::Windows | Self::Wayland)
|
||||
}
|
||||
}
|
||||
|
||||
/// How HDR values reach the display.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrEncoding {
|
||||
/// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB,
|
||||
/// Wayland extended linear).
|
||||
ExtendedLinear,
|
||||
/// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ).
|
||||
Pq10,
|
||||
}
|
||||
|
||||
/// The display output decision.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayOutput {
|
||||
Sdr { reason: &'static str },
|
||||
Hdr { format: TextureFormat, encoding: HdrEncoding },
|
||||
}
|
||||
|
||||
impl DisplayOutput {
|
||||
#[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR
|
||||
pub fn is_hdr(self) -> bool {
|
||||
matches!(self, Self::Hdr { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoding a surface format carries on `platform`, if it is an HDR one.
|
||||
fn encoding_of(format: TextureFormat, platform: Platform) -> Option<HdrEncoding> {
|
||||
match format {
|
||||
TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear),
|
||||
// macOS takes HDR through EDR floats only
|
||||
TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The best HDR surface format among those a surface offers (`available`), for `platform`:
|
||||
/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only.
|
||||
/// (For configuring the surface once eframe lets the app pick its format.)
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option<TextureFormat> {
|
||||
if !platform.has_hdr_path() {
|
||||
return None;
|
||||
}
|
||||
[TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some())
|
||||
}
|
||||
|
||||
/// The decision for the window: `target` is the format the renderer configured (`None`: the glow
|
||||
/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the
|
||||
/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS),
|
||||
/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`).
|
||||
pub fn choose(target: Option<TextureFormat>, platform: Platform, display_headroom: Option<f32>, disabled: bool) -> DisplayOutput {
|
||||
if disabled {
|
||||
return DisplayOutput::Sdr { reason: "HDR display is switched off" };
|
||||
}
|
||||
if !platform.has_hdr_path() {
|
||||
return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" };
|
||||
}
|
||||
let Some(format) = target else {
|
||||
return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" };
|
||||
};
|
||||
let Some(encoding) = encoding_of(format, platform) else {
|
||||
return DisplayOutput::Sdr { reason: "the window surface is 8-bit" };
|
||||
};
|
||||
match display_headroom {
|
||||
Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding },
|
||||
Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" },
|
||||
None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" },
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision for this start, logged once (the window renderer's `target` format).
|
||||
pub fn report(target: Option<TextureFormat>) -> DisplayOutput {
|
||||
let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"));
|
||||
// no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready
|
||||
let out = choose(target, Platform::current(), None, disabled);
|
||||
match out {
|
||||
DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"),
|
||||
DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use TextureFormat as F;
|
||||
|
||||
#[test]
|
||||
fn eight_bit_surfaces_are_sdr() {
|
||||
for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] {
|
||||
for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] {
|
||||
assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_needs_platform_format_and_headroom() {
|
||||
let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear };
|
||||
assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr);
|
||||
assert_eq!(
|
||||
choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false),
|
||||
DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 }
|
||||
);
|
||||
// any missing condition falls back to SDR
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr());
|
||||
assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr());
|
||||
assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr());
|
||||
// macOS has no PQ swap chain path
|
||||
assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_format_picks_float_then_pq() {
|
||||
let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float];
|
||||
assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float));
|
||||
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm));
|
||||
assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None);
|
||||
assert_eq!(preferred_hdr_format(&all, Platform::X11), None);
|
||||
assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,12 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod appearance;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod colord;
|
||||
mod control_server;
|
||||
mod dialog_filter;
|
||||
mod editors;
|
||||
mod hdr_display;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
@@ -64,7 +67,7 @@ impl eframe::App for App {
|
||||
// Quit from the menu bar: save exactly what closing the window saves (settings and
|
||||
// library, after the unsaved-changes check; otherwise its prompt shows), then end the
|
||||
// process instead of closing the viewport while AppKit is terminating (PR #444).
|
||||
eframe::App::on_exit(self);
|
||||
eframe::App::on_exit(self, None);
|
||||
std::process::exit(0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
@@ -87,6 +90,16 @@ impl eframe::App for App {
|
||||
if !self.0.shutdown(EXIT_WAIT) {
|
||||
log::warn!("quit: background work was still running after {EXIT_WAIT:?}");
|
||||
}
|
||||
// macOS (#1331): end the process here, before eframe tears the window down, as Quit from
|
||||
// the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's
|
||||
// content view and restores its class on teardown; AppKit's Touch Bar support observes
|
||||
// that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation
|
||||
// invalidate]` throws "not registered" and closing the window ends in a crash report.
|
||||
// Nothing after this is needed: settings and library are saved, background work has
|
||||
// stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a
|
||||
// renderer that fails to start still relaunches on the next one (`window_gpu`).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +462,16 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
pick_folder: Some(Box::new(|| {
|
||||
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
#[cfg(target_os = "linux")]
|
||||
open_model_folder: Some(std::sync::Arc::new(|path: &str| {
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.status()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) })
|
||||
})),
|
||||
open_with: Some(Box::new(editors::open_with)),
|
||||
open_all_with: Some(Box::new(editors::open_all_with)),
|
||||
check_editor: Some(Box::new(editors::check)),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if !url.starts_with("https://") {
|
||||
@@ -525,7 +547,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
|
||||
#[cfg(target_os = "macos")]
|
||||
screen_profile: Some(Box::new(screen_profile::icc)),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// Linux: the profile the desktop assigns in colord, read off the UI thread
|
||||
#[cfg(target_os = "linux")]
|
||||
screen_profile: Some(colord::service()),
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
screen_profile: None,
|
||||
pick_display_profile: Some(Box::new(|| {
|
||||
let mut d = file_dialog()
|
||||
@@ -649,7 +674,8 @@ OPTIONS:
|
||||
--version, --help
|
||||
|
||||
ENVIRONMENT:
|
||||
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
|
||||
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder).
|
||||
Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData)
|
||||
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
|
||||
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
|
||||
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
@@ -833,6 +859,8 @@ fn main() -> eframe::Result {
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
// and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session = session.with_default_sam3_model();
|
||||
// HDR display: SDR unless the surface, platform and display all allow HDR
|
||||
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
|
||||
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
@@ -842,6 +870,7 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
@@ -1207,6 +1236,35 @@ mod tests {
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
|
||||
/// The check runs in a child (this test binary again), since on macOS it ends the process.
|
||||
#[test]
|
||||
fn on_exit_ends_the_process_on_macos() {
|
||||
if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
|
||||
let mut app = App(
|
||||
app,
|
||||
PrefsWriter::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
);
|
||||
println!("calling on_exit");
|
||||
eframe::App::on_exit(&mut app, None);
|
||||
println!("on_exit returned");
|
||||
return;
|
||||
}
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
|
||||
.env("LIGHTCRAFT_TEST_ON_EXIT", "1")
|
||||
.env("LIGHTCRAFT_NO_PREFS", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(out.status.success(), "{stdout}");
|
||||
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
|
||||
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -27,3 +27,7 @@ sync_github_name = true
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
|
||||
[people.ArekatlaNishanthchowdary]
|
||||
real_name = "Arekatla Nishanth Chowdary"
|
||||
sync_github_name = true
|
||||
@@ -50,6 +50,7 @@
|
||||
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
|
||||
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
||||
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
|
||||
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -74,7 +75,7 @@ pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 7;
|
||||
pub const VERSION: u32 = 8;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -112,6 +113,8 @@ pub struct LoadReport {
|
||||
/// The library was in an older catalog format (`0` = before format versions: a log without a
|
||||
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
|
||||
pub upgraded_from: Option<u32>,
|
||||
/// What [`Catalog::repair`] changed in a damaged snapshot (empty for a sound one).
|
||||
pub repaired: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
@@ -327,7 +330,12 @@ impl Journal {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
}
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
(s.catalog, s.seq, Some(h.version))
|
||||
let mut catalog = s.catalog;
|
||||
report.repaired = catalog.repair();
|
||||
for r in &report.repaired {
|
||||
log::warn!("{SNAPSHOT}: {r}");
|
||||
}
|
||||
(catalog, s.seq, Some(h.version))
|
||||
}
|
||||
None => (Catalog::new(), 0, None),
|
||||
};
|
||||
|
||||
@@ -236,63 +236,84 @@ pub struct KeywordNode {
|
||||
impl Catalog {
|
||||
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
|
||||
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
|
||||
use std::collections::HashMap;
|
||||
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
|
||||
// path as first written, and the photos counted (once per photo, even when two of its
|
||||
// keywords share a parent)
|
||||
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
|
||||
struct Level {
|
||||
name: String,
|
||||
sort: String,
|
||||
path: String,
|
||||
count: usize,
|
||||
parent: Option<String>,
|
||||
seen: usize,
|
||||
}
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut this_photo: HashSet<String> = HashSet::new();
|
||||
let mut levels: Vec<Level> = Vec::new();
|
||||
let mut by_lower: HashMap<String, usize> = HashMap::new();
|
||||
// each level's children (by index), and the top level
|
||||
let mut kids: Vec<Vec<usize>> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
// the keyword list first (no photos counted): a listed keyword is there without photos
|
||||
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
|
||||
// each keyword as written → its levels (its own and those above it), worked out once: a
|
||||
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
|
||||
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
|
||||
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
|
||||
this_photo.clear();
|
||||
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
|
||||
for k in keywords {
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
let mut parent: Option<String> = None;
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
let chain = chains.entry(k.as_str()).or_insert_with(|| {
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
}
|
||||
path.push_str(part);
|
||||
let sort = part.to_lowercase();
|
||||
lower.push_str(&sort);
|
||||
let i = match by_lower.get(&lower) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = levels.len();
|
||||
match chain.last().and_then(|&p| kids.get_mut(p)) {
|
||||
Some(k) => k.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
|
||||
kids.push(Vec::new());
|
||||
by_lower.insert(lower.clone(), i);
|
||||
i
|
||||
}
|
||||
};
|
||||
chain.push(i);
|
||||
}
|
||||
path.push_str(part);
|
||||
lower.push_str(&part.to_lowercase());
|
||||
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
|
||||
name: part.to_string(),
|
||||
path: path.clone(),
|
||||
count: 0,
|
||||
parent: parent.clone(),
|
||||
});
|
||||
if counts && this_photo.insert(lower.clone()) {
|
||||
l.count += 1;
|
||||
chain
|
||||
});
|
||||
if counts {
|
||||
for &i in chain.iter() {
|
||||
let Some(l) = levels.get_mut(i) else { continue };
|
||||
// photo 0 is the keyword list, which counts nothing
|
||||
if l.seen != photo {
|
||||
l.seen = photo;
|
||||
l.count += 1;
|
||||
}
|
||||
}
|
||||
parent = Some(lower.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
|
||||
for (lower, l) in &levels {
|
||||
kids.entry(l.parent.clone()).or_default().push(lower.clone());
|
||||
}
|
||||
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
|
||||
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
|
||||
list.sort_by_key(|l| levels[l].name.to_lowercase());
|
||||
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
|
||||
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
|
||||
list.into_iter()
|
||||
.map(|l| {
|
||||
let lv = &levels[&l];
|
||||
let children = build(Some(l.clone()), levels, kids);
|
||||
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
|
||||
.filter_map(|i| {
|
||||
let lv = levels.get(i)?;
|
||||
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
|
||||
let children = build(below, levels, kids);
|
||||
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
build(None, &levels, &mut kids)
|
||||
build(roots, &levels, &mut kids)
|
||||
}
|
||||
|
||||
/// `SetMeta` ops for every photo whose keywords `f` changes.
|
||||
@@ -804,6 +825,15 @@ mod tests {
|
||||
assert_eq!(c.query(&f, &Default::default()).len(), 3);
|
||||
}
|
||||
|
||||
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
|
||||
/// photo with it counts again.
|
||||
#[test]
|
||||
fn tree_counts_a_repeated_keyword_once_per_photo() {
|
||||
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
|
||||
let t = c.keyword_tree();
|
||||
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_delete_merge_are_single_undoable_batches() {
|
||||
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
|
||||
|
||||
+122
-10
@@ -125,6 +125,12 @@ pub enum Op {
|
||||
id: PhotoId,
|
||||
step: HistoryStep,
|
||||
},
|
||||
/// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps.
|
||||
SetHistoryCursor {
|
||||
id: PhotoId,
|
||||
#[serde(default)]
|
||||
cursor: Option<usize>,
|
||||
},
|
||||
AddAlbum {
|
||||
album: Album,
|
||||
},
|
||||
@@ -284,19 +290,21 @@ impl Catalog {
|
||||
|
||||
// ---- ids
|
||||
|
||||
// (saturating: a counter a damaged snapshot left at the top hands out its last id again, which
|
||||
// the op that adds it then refuses as taken, rather than overflowing)
|
||||
pub fn alloc_photo_id(&mut self) -> PhotoId {
|
||||
let id = PhotoId(self.next_photo.max(1));
|
||||
self.next_photo = id.0 + 1;
|
||||
self.next_photo = id.0.saturating_add(1);
|
||||
id
|
||||
}
|
||||
pub fn alloc_album_id(&mut self) -> AlbumId {
|
||||
let id = AlbumId(self.next_album.max(1));
|
||||
self.next_album = id.0 + 1;
|
||||
self.next_album = id.0.saturating_add(1);
|
||||
id
|
||||
}
|
||||
pub fn alloc_stack_id(&mut self) -> StackId {
|
||||
let id = StackId(self.next_stack.max(1));
|
||||
self.next_stack = id.0 + 1;
|
||||
self.next_stack = id.0.saturating_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
@@ -491,7 +499,7 @@ impl Catalog {
|
||||
return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id)));
|
||||
}
|
||||
let id = photo.id;
|
||||
self.next_photo = self.next_photo.max(id.0 + 1);
|
||||
self.next_photo = self.next_photo.max(id.0.saturating_add(1));
|
||||
self.photos.insert(id, Arc::new(*photo));
|
||||
Op::RemovePhoto { id }
|
||||
}
|
||||
@@ -545,12 +553,25 @@ impl Catalog {
|
||||
Op::PushHistory { id, step } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
let old = p.history.clone();
|
||||
let old_cursor = p.history_cursor.take();
|
||||
// a new edit made from an earlier step drops the steps after it
|
||||
if let Some(c) = old_cursor {
|
||||
p.history.truncate(c.saturating_add(1));
|
||||
}
|
||||
p.history.push(step);
|
||||
if p.history.len() > HISTORY_LIMIT {
|
||||
let n = p.history.len() - HISTORY_LIMIT;
|
||||
p.history.drain(..n);
|
||||
}
|
||||
Op::SetHistory { id, history: old }
|
||||
if old_cursor.is_none() {
|
||||
Op::SetHistory { id, history: old }
|
||||
} else {
|
||||
Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] }
|
||||
}
|
||||
}
|
||||
Op::SetHistoryCursor { id, cursor } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) }
|
||||
}
|
||||
Op::AddAlbum { album } => {
|
||||
if self.albums.contains_key(&album.id) {
|
||||
@@ -568,7 +589,7 @@ impl Catalog {
|
||||
self.validate_rules(rules)?;
|
||||
}
|
||||
let id = album.id;
|
||||
self.next_album = self.next_album.max(id.0 + 1);
|
||||
self.next_album = self.next_album.max(id.0.saturating_add(1));
|
||||
self.albums.insert(id, album);
|
||||
Op::RemoveAlbum { id }
|
||||
}
|
||||
@@ -588,13 +609,15 @@ impl Catalog {
|
||||
if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) {
|
||||
return Err(CatalogError::Invalid("parent must be another folder".into()));
|
||||
}
|
||||
// no cycles
|
||||
// no cycles (bounded: a loop elsewhere in a damaged catalog must not hang this)
|
||||
let mut cur = Some(p);
|
||||
let mut steps = 0usize;
|
||||
while let Some(c) = cur {
|
||||
if c == id {
|
||||
if c == id || steps > self.albums.len() {
|
||||
return Err(CatalogError::Invalid("cannot move a folder into itself".into()));
|
||||
}
|
||||
cur = self.albums.get(&c).and_then(|a| a.parent);
|
||||
steps += 1;
|
||||
}
|
||||
}
|
||||
let a = self.album_mut(id)?;
|
||||
@@ -640,7 +663,7 @@ impl Catalog {
|
||||
}
|
||||
self.validate_stack(stack.id, &stack.photos)?;
|
||||
let id = stack.id;
|
||||
self.next_stack = self.next_stack.max(id.0 + 1);
|
||||
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
|
||||
self.stacks.insert(id, stack);
|
||||
Op::RemoveStack { id }
|
||||
}
|
||||
@@ -804,7 +827,94 @@ impl Catalog {
|
||||
}
|
||||
|
||||
pub fn from_snapshot(s: &str) -> Result<Catalog> {
|
||||
serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))
|
||||
let mut c: Catalog = serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))?;
|
||||
c.repair();
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left
|
||||
/// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it
|
||||
/// finds in them, and walks folders up to the top): every photo, album and stack carries the
|
||||
/// id it is filed under; the id counters lie past every id in use; a stack keeps only photos
|
||||
/// in the catalog, once each, and goes when fewer than two are left; an album whose parent is
|
||||
/// missing, not a folder or inside itself moves to the top level. Returns what was changed
|
||||
/// (nothing, for a catalog this build wrote).
|
||||
pub fn repair(&mut self) -> Vec<String> {
|
||||
let mut changed = Vec::new();
|
||||
for (id, p) in self.photos.iter_mut() {
|
||||
if p.id != *id {
|
||||
changed.push(format!("photo {} was filed as {}", p.id.0, id.0));
|
||||
Arc::make_mut(p).id = *id;
|
||||
}
|
||||
}
|
||||
for (id, a) in self.albums.iter_mut() {
|
||||
if a.id != *id {
|
||||
changed.push(format!("album {} was filed as {}", a.id.0, id.0));
|
||||
a.id = *id;
|
||||
}
|
||||
}
|
||||
let photos = &self.photos;
|
||||
self.stacks.retain(|id, st| {
|
||||
if st.id != *id {
|
||||
changed.push(format!("stack {} was filed as {}", st.id.0, id.0));
|
||||
st.id = *id;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let before = st.photos.len();
|
||||
st.photos.retain(|p| photos.contains_key(p) && seen.insert(*p));
|
||||
if st.photos.len() != before {
|
||||
changed.push(format!("stack {}: {} missing or repeated photo(s) left out", id.0, before - st.photos.len()));
|
||||
}
|
||||
let keep = st.photos.len() >= 2;
|
||||
if !keep {
|
||||
changed.push(format!("stack {} had fewer than two photos: removed", id.0));
|
||||
}
|
||||
keep
|
||||
});
|
||||
// a photo in two stacks stays in the first
|
||||
let mut stacked = std::collections::HashSet::new();
|
||||
self.stacks.retain(|id, st| {
|
||||
let before = st.photos.len();
|
||||
st.photos.retain(|p| stacked.insert(*p));
|
||||
if st.photos.len() != before {
|
||||
changed.push(format!("stack {}: {} photo(s) already in another stack left out", id.0, before - st.photos.len()));
|
||||
}
|
||||
st.photos.len() >= 2
|
||||
});
|
||||
let parents: Vec<(AlbumId, Option<AlbumId>)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
|
||||
for (id, parent) in parents {
|
||||
let Some(p) = parent else { continue };
|
||||
let mut cur = Some(p);
|
||||
let mut steps = 0usize;
|
||||
let ok = loop {
|
||||
match cur {
|
||||
None => break true,
|
||||
Some(c) if c == id || steps > self.albums.len() => break false,
|
||||
Some(c) => match self.albums.get(&c) {
|
||||
Some(a) if a.folder => cur = a.parent,
|
||||
_ => break false,
|
||||
},
|
||||
}
|
||||
steps += 1;
|
||||
};
|
||||
if !ok && let Some(a) = self.albums.get_mut(&id) {
|
||||
changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
|
||||
a.parent = None;
|
||||
}
|
||||
}
|
||||
let past = |last: Option<u64>| last.map_or(1, |v| v.saturating_add(1));
|
||||
let (photo, album, stack) = (
|
||||
past(self.photos.keys().last().map(|k| k.0)),
|
||||
past(self.albums.keys().last().map(|k| k.0)),
|
||||
past(self.stacks.keys().last().map(|k| k.0)),
|
||||
);
|
||||
if self.next_photo < photo || self.next_album < album || self.next_stack < stack {
|
||||
changed.push("id counters behind the ids in use: moved past them".into());
|
||||
self.next_photo = self.next_photo.max(photo);
|
||||
self.next_album = self.next_album.max(album);
|
||||
self.next_stack = self.next_stack.max(stack);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored.
|
||||
@@ -859,4 +969,6 @@ mod tests_local;
|
||||
#[cfg(test)]
|
||||
mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_repair;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -279,6 +279,7 @@ impl Op {
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetHistoryCursor { id, .. }
|
||||
| Op::SetCaptured { id, .. }
|
||||
| Op::SetAnalysis { id, .. }
|
||||
| Op::SetFile { id, .. }
|
||||
|
||||
@@ -135,8 +135,9 @@ impl LibraryLock {
|
||||
}
|
||||
|
||||
/// Another descriptor on the locked file: what a process spawned by another thread holds
|
||||
/// between fork and exec (a `dup` refers to the same open file description). Tests only.
|
||||
#[cfg(test)]
|
||||
/// between fork and exec (a `dup` refers to the same open file description). Tests only (the
|
||||
/// test that uses it is Unix-only: `flock` semantics).
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) fn descriptor_copy(&self) -> Option<File> {
|
||||
self.file.as_ref().and_then(|f| f.try_clone().ok())
|
||||
}
|
||||
|
||||
@@ -247,6 +247,11 @@ pub struct Photo {
|
||||
pub versions: Vec<Version>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<HistoryStep>,
|
||||
/// The History step the photo's settings are at, when it isn't the latest one (Go to History
|
||||
/// Step moved back): later steps stay listed and can be returned to until a new edit drops
|
||||
/// them. `None` (and catalogs written before the cursor existed) = the latest step.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub history_cursor: Option<usize>,
|
||||
/// In "Recently Deleted".
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
@@ -318,15 +323,22 @@ pub fn relative_wb_format(format: &str) -> bool {
|
||||
|
||||
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
|
||||
/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs):
|
||||
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera
|
||||
/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into
|
||||
/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730).
|
||||
/// The engine's render path (`files.rs`) decides the same way from the file; both must agree.
|
||||
/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor
|
||||
/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin:
|
||||
/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays
|
||||
/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through
|
||||
/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree.
|
||||
pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
|
||||
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none()
|
||||
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
|
||||
pub fn history_index(&self) -> Option<usize> {
|
||||
let last = self.history.len().checked_sub(1)?;
|
||||
Some(self.history_cursor.map_or(last, |c| c.min(last)))
|
||||
}
|
||||
|
||||
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
|
||||
Photo {
|
||||
id,
|
||||
@@ -347,6 +359,7 @@ impl Photo {
|
||||
edited: None,
|
||||
versions: Vec::new(),
|
||||
history: Vec::new(),
|
||||
history_cursor: None,
|
||||
deleted: false,
|
||||
local: false,
|
||||
duration: None,
|
||||
@@ -599,6 +612,8 @@ mod edited_tests {
|
||||
("FUJIFILM X-T4", "RAF", false),
|
||||
("ILCE-7M3", "ARW", false),
|
||||
("SONY ILCE-7M2", "ARW", true),
|
||||
("SONY ILCA-99M2", "ARW", false),
|
||||
("SONY SLT-A33", "ARW", false),
|
||||
("SONY ILCE-1", "ARW", true),
|
||||
("", "ARW", true),
|
||||
("SONY ILCE-7M3", "DNG", false),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Filtering, search and sorting.
|
||||
|
||||
use std::cmp::Ordering::Equal;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
|
||||
@@ -251,8 +253,8 @@ impl Filter {
|
||||
return false;
|
||||
}
|
||||
match &self.folder {
|
||||
// browsed photos only show in folder views
|
||||
None if p.local => return false,
|
||||
// browsed photos only show in folder views, and in Recently Deleted once deleted (#1430)
|
||||
None if p.local && !p.deleted => return false,
|
||||
None => {}
|
||||
Some(dir) => {
|
||||
let crate::Source::File { path } = &p.source else { return false };
|
||||
@@ -359,8 +361,13 @@ impl Filter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`).
|
||||
pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase))
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
|
||||
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
|
||||
let root = filter.library_root();
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
@@ -369,11 +376,15 @@ impl Catalog {
|
||||
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
|
||||
SortKey::ImportDate => a.imported.cmp(&b.imported),
|
||||
SortKey::EditDate => a.edited.cmp(&b.edited),
|
||||
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
|
||||
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
|
||||
SortKey::Rating => a.rating.cmp(&b.rating),
|
||||
SortKey::FileSize => a.file_size.cmp(&b.file_size),
|
||||
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
|
||||
}
|
||||
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
|
||||
// order cameras number frames, so the order doesn't depend on how ids were allocated;
|
||||
// the id only keeps it stable.
|
||||
.then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
|
||||
.then(a.id.cmp(&b.id));
|
||||
if sort.ascending { o } else { o.reverse() }
|
||||
});
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
//! it is then simply exported again.
|
||||
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
|
||||
//! and sync, plus a read-back check, and the file then appears under its final name **without
|
||||
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
|
||||
//! volume has no hard links).
|
||||
//! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard
|
||||
//! links, an exclusive create that claims the name, then a rename over that placeholder).
|
||||
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
|
||||
//! resolved paths elsewhere), for "is this target an original?" checks.
|
||||
|
||||
@@ -226,16 +226,24 @@ fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
|
||||
// no hard links on this volume (FAT/exFAT, some shares): check, then rename
|
||||
Err(_) => {
|
||||
if fs::symlink_metadata(path).is_ok() {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
|
||||
}
|
||||
fs::rename(tmp, path)
|
||||
}
|
||||
// no hard links on this volume (FAT/exFAT, some shares)
|
||||
Err(_) => publish_new_by_rename(tmp, path, &|| {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback of [`publish_new`]: reserve the name with an exclusive create (atomic, so a file that
|
||||
/// appears at the same time is never replaced), then rename the complete temp file over our own
|
||||
/// empty placeholder. `before_claim` runs just before the name is claimed (a test hook).
|
||||
fn publish_new_by_rename(tmp: &Path, path: &Path, before_claim: &dyn Fn()) -> io::Result<()> {
|
||||
before_claim();
|
||||
OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
if let Err(e) = fs::rename(tmp, path) {
|
||||
let _ = fs::remove_file(path);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does the file at `path` hold exactly `data`?
|
||||
fn same_content(path: &Path, data: &[u8]) -> io::Result<bool> {
|
||||
use std::io::Read;
|
||||
@@ -296,6 +304,22 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_publish_never_replaces_a_file_that_appears_at_the_same_time() {
|
||||
let d = temp("nolink-race");
|
||||
let tmp = d.join(".tmp-new");
|
||||
let dest = d.join("IMG_1.dng");
|
||||
fs::write(&tmp, b"new").unwrap();
|
||||
let e = publish_new_by_rename(&tmp, &dest, &|| fs::write(&dest, b"other writer").unwrap()).unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&dest).unwrap(), b"other writer");
|
||||
fs::remove_file(&dest).unwrap();
|
||||
publish_new_by_rename(&tmp, &dest, &|| {}).unwrap();
|
||||
assert_eq!(fs::read(&dest).unwrap(), b"new");
|
||||
assert!(!tmp.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_and_leaves_no_temp() {
|
||||
let d = temp("replace");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::query::cmp_name;
|
||||
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
|
||||
|
||||
impl Catalog {
|
||||
@@ -24,7 +25,7 @@ impl Catalog {
|
||||
}
|
||||
/// Photo → (stack, position in the stack), for every stacked photo.
|
||||
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
|
||||
self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect()
|
||||
self.stacks.iter().flat_map(|(id, s)| s.photos.iter().enumerate().map(move |(i, p)| (*p, (*id, i)))).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> {
|
||||
@@ -69,7 +70,7 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
for sid in &touched {
|
||||
for p in self.stacks[sid].photos.clone() {
|
||||
for p in self.stacks.get(sid).map(|s| s.photos.clone()).unwrap_or_default() {
|
||||
push(&mut order, p);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +186,10 @@ impl Catalog {
|
||||
if !done.insert(*sid) {
|
||||
continue;
|
||||
}
|
||||
let s = &self.stacks[sid];
|
||||
let Some(s) = self.stacks.get(sid) else {
|
||||
out.push(*id);
|
||||
continue;
|
||||
};
|
||||
let members = s.photos.iter().copied().filter(|p| present.contains(p));
|
||||
if s.collapsed {
|
||||
out.extend(members.take(1));
|
||||
@@ -203,17 +207,19 @@ impl Catalog {
|
||||
/// Only runs of two or more are returned.
|
||||
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
|
||||
let index = self.stack_index();
|
||||
let mut timed: Vec<(i64, PhotoId)> = ids
|
||||
let mut timed: Vec<(i64, &str, PhotoId)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| self.photos.get(id))
|
||||
.filter(|p| !p.deleted && !index.contains_key(&p.id))
|
||||
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id)))
|
||||
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id)))
|
||||
.collect();
|
||||
timed.sort();
|
||||
timed.dedup_by_key(|(_, id)| *id);
|
||||
// a burst within one second stays in file name order (the first file is the stack's cover)
|
||||
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
|
||||
// equal ids are the same photo, so same time and name: adjacent after the sort
|
||||
timed.dedup_by_key(|(_, _, id)| *id);
|
||||
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
|
||||
let mut last: Option<i64> = None;
|
||||
for (t, id) in timed {
|
||||
for (t, _, id) in timed {
|
||||
match (last, groups.last_mut()) {
|
||||
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
|
||||
_ => groups.push(vec![id]),
|
||||
@@ -264,6 +270,22 @@ mod tests {
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_stack_keeps_a_burst_in_file_order() {
|
||||
let mut c = Catalog::new();
|
||||
let mut by_name = Vec::new();
|
||||
for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() {
|
||||
let id = PhotoId(1000 - n as u64);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
|
||||
p.captured = Some("2026-04-01T10:00:00".into());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
by_name.push(id);
|
||||
}
|
||||
let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0);
|
||||
assert_eq!(groups, vec![by_name.clone()]);
|
||||
assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_seconds_parses() {
|
||||
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
|
||||
|
||||
@@ -694,3 +694,46 @@ fn undated_photos_group_under_unknown_date_and_sort_together() {
|
||||
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
|
||||
}
|
||||
|
||||
/// Photos with one import time and capture time, as one import batch or a burst gives them, added
|
||||
/// with ids that run opposite to their names.
|
||||
fn batch_with_reversed_ids(c: &mut Catalog) -> Vec<PhotoId> {
|
||||
let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"];
|
||||
let mut ids = Vec::new();
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
let id = PhotoId(1000 - n as u64);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00");
|
||||
p.captured = Some("2026-10-01T12:00:00".into());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
ids // in name order
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_import_batch_sorts_by_file_name_not_id() {
|
||||
let mut c = Catalog::new();
|
||||
let by_name = batch_with_reversed_ids(&mut c);
|
||||
let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() };
|
||||
assert_eq!(c.query(&Filter::default(), &sort), by_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_in_one_second_sorts_by_file_name_not_id() {
|
||||
let mut c = Catalog::new();
|
||||
let by_name = batch_with_reversed_ids(&mut c);
|
||||
let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() };
|
||||
assert_eq!(c.query(&Filter::default(), &sort), by_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmp_name_ignores_case_without_allocating() {
|
||||
use crate::query::cmp_name;
|
||||
use std::cmp::Ordering::*;
|
||||
assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal);
|
||||
assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal);
|
||||
assert_eq!(cmp_name("a", "B"), Less);
|
||||
assert_eq!(cmp_name("B", "a"), Greater);
|
||||
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
|
||||
assert_eq!(cmp_name("a", "ab"), Less);
|
||||
}
|
||||
@@ -237,6 +237,23 @@ fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
|
||||
assert!(!f.matches(c.photo(kept).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_browsed_photo_shows_in_recently_deleted_and_comes_back() {
|
||||
let mut c = Catalog::new();
|
||||
let browsed = add_with(&mut c, "/pics/trip/a.jpg", |p| p.local = true);
|
||||
let recently_deleted = Filter { deleted: true, ..Default::default() };
|
||||
let library = Filter::default();
|
||||
assert!(!recently_deleted.matches(c.photo(browsed).unwrap(), &c));
|
||||
assert!(!library.matches(c.photo(browsed).unwrap(), &c), "browsed photos stay out of the library view");
|
||||
|
||||
c.apply(Op::SetDeleted { id: browsed, deleted: true }).unwrap();
|
||||
assert_eq!(c.query(&recently_deleted, &Sort::default()), vec![browsed], "#1430: it was invisible everywhere");
|
||||
assert!(c.query(&library, &Sort::default()).is_empty());
|
||||
|
||||
c.apply(Op::SetDeleted { id: browsed, deleted: false }).unwrap();
|
||||
assert!(c.query(&recently_deleted, &Sort::default()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_folder_choice_is_no_choice() {
|
||||
let c = library(&["/pics/a.jpg"]);
|
||||
|
||||
@@ -211,8 +211,9 @@ fn op_variants_are_versioned() {
|
||||
Op::SetKeyword { .. } => 5,
|
||||
Op::SetFolderRecord { .. } => 6,
|
||||
Op::SetAsShotWb { .. } => 7,
|
||||
Op::SetHistoryCursor { .. } => 8,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None });
|
||||
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -140,8 +140,7 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
|
||||
std::thread::spawn(move || {
|
||||
let mut spawned = 0usize;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Ok(mut c) =
|
||||
std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
||||
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
||||
{
|
||||
let _ = c.wait();
|
||||
spawned += 1;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! A damaged or hand-edited snapshot that still parses is made to hold together on load
|
||||
//! ([`Catalog::repair`]): found by fuzzing snapshots of a real library.
|
||||
|
||||
use crate::journal::SNAPSHOT;
|
||||
use crate::*;
|
||||
|
||||
fn album(id: u64, parent: Option<u64>, folder: bool, photos: Vec<PhotoId>) -> Album {
|
||||
Album {
|
||||
id: AlbumId(id),
|
||||
name: format!("A{id}"),
|
||||
parent: parent.map(AlbumId),
|
||||
folder,
|
||||
photos,
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog of four photos, a stack of two, a folder with an album in it.
|
||||
fn sound() -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..4 {
|
||||
let id = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{i}.jpg") }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01")),
|
||||
})
|
||||
.unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let sid = c.alloc_stack_id();
|
||||
c.apply(Op::AddStack { stack: Stack { id: sid, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap();
|
||||
let (f, a) = (c.alloc_album_id(), c.alloc_album_id());
|
||||
c.apply(Op::AddAlbum { album: album(f.0, None, true, vec![]) }).unwrap();
|
||||
c.apply(Op::AddAlbum { album: album(a.0, Some(f.0), false, vec![ids[2]]) }).unwrap();
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
/// The snapshot file of `c` with `edit` applied to its JSON, loaded through the journal.
|
||||
fn load_edited(c: &Catalog, edit: impl FnOnce(&mut serde_json::Value)) -> (Catalog, LoadReport) {
|
||||
let m = MemStore::new();
|
||||
let (mut j, _, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
j.snapshot(c).unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
|
||||
edit(&mut v["catalog"]);
|
||||
m.set(SNAPSHOT, serde_json::to_vec(&v).unwrap());
|
||||
drop(j);
|
||||
let (_, c, report) = Journal::open(Box::new(m)).unwrap();
|
||||
(c, report)
|
||||
}
|
||||
|
||||
/// A stack whose `id` differs from the key it is filed under made `arrange_stacks` index the map
|
||||
/// with an id it doesn't hold, which panics: the grid of any library with such a stack.
|
||||
#[test]
|
||||
fn a_stack_filed_under_another_id_is_arranged() {
|
||||
let (c, ids) = sound();
|
||||
let (c, report) = load_edited(&c, |v| {
|
||||
let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
|
||||
v["stacks"][&key]["id"] = 77.into();
|
||||
});
|
||||
assert!(report.repaired.iter().any(|r| r.contains("stack 77 was filed as")), "{:?}", report.repaired);
|
||||
assert_eq!(c.arrange_stacks(&ids), vec![ids[0], ids[2], ids[3]], "the collapsed stack shows its top");
|
||||
assert!(c.stacks().all(|s| c.stack_of(s.photos[0]).is_some_and(|t| t.id == s.id)));
|
||||
}
|
||||
|
||||
/// Stacks of missing, repeated or too few photos (or one photo in two stacks) are put right: a
|
||||
/// stack of none had no top (`photos[0]`).
|
||||
#[test]
|
||||
fn stacks_keep_only_photos_in_the_catalog() {
|
||||
let (c, ids) = sound();
|
||||
let (c, report) = load_edited(&c, |v| {
|
||||
let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
|
||||
v["stacks"][&key]["photos"] = serde_json::json!([ids[0].0, 999, ids[0].0, ids[1].0]);
|
||||
v["stacks"]["8"] = serde_json::json!({"id": 8, "photos": []});
|
||||
v["stacks"]["9"] = serde_json::json!({"id": 9, "photos": [ids[1].0, ids[3].0, ids[2].0]});
|
||||
});
|
||||
let stacks: Vec<(StackId, Vec<PhotoId>)> = c.stacks().map(|s| (s.id, s.photos.clone())).collect();
|
||||
assert_eq!(stacks.len(), 2, "{stacks:?} {:?}", report.repaired);
|
||||
assert_eq!(stacks[0].1, vec![ids[0], ids[1]], "missing and repeated photos left out");
|
||||
assert_eq!(stacks[1].1, vec![ids[3], ids[2]], "a photo already in a stack stays in the first");
|
||||
assert!(!c.arrange_stacks(&ids).is_empty());
|
||||
}
|
||||
|
||||
/// Folders inside each other (a loop), or under an album or a missing folder, move to the top
|
||||
/// level: walking up from them never ends otherwise, and moving any folder there hung.
|
||||
#[test]
|
||||
fn folder_loops_and_missing_parents_move_to_the_top() {
|
||||
let (c, _) = sound();
|
||||
let (mut c, report) = load_edited(&c, |v| {
|
||||
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), true, vec![])).unwrap();
|
||||
v["albums"]["11"] = serde_json::to_value(album(11, Some(10), true, vec![])).unwrap();
|
||||
v["albums"]["12"] = serde_json::to_value(album(12, Some(12), true, vec![])).unwrap();
|
||||
v["albums"]["13"] = serde_json::to_value(album(13, Some(404), false, vec![])).unwrap();
|
||||
});
|
||||
assert!(!report.repaired.is_empty());
|
||||
for id in [10, 11, 12, 13] {
|
||||
let a = c.album(AlbumId(id)).unwrap();
|
||||
assert!(
|
||||
a.parent.is_none() || a.parent.is_some_and(|p| c.album(p).is_some_and(|f| f.folder && f.parent.is_none())),
|
||||
"album {id}: {:?}",
|
||||
a.parent
|
||||
);
|
||||
}
|
||||
// moving a folder around terminates either way
|
||||
let _ = c.apply(Op::MoveAlbum { id: AlbumId(10), parent: Some(AlbumId(11)) });
|
||||
let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) });
|
||||
}
|
||||
|
||||
/// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer
|
||||
/// overflows (a panic in debug builds) and never hands out an id already in use.
|
||||
#[test]
|
||||
fn id_counters_are_moved_past_the_ids_in_use() {
|
||||
let (c, ids) = sound();
|
||||
let (mut c, report) = load_edited(&c, |v| {
|
||||
v["next_photo"] = 1.into();
|
||||
v["next_album"] = 1.into();
|
||||
v["next_stack"] = 1.into();
|
||||
});
|
||||
assert!(report.repaired.iter().any(|r| r.contains("id counters")), "{:?}", report.repaired);
|
||||
assert!(c.alloc_photo_id().0 > ids[3].0);
|
||||
let (mut c, _) = load_edited(&c, |v| v["next_photo"] = u64::MAX.into());
|
||||
assert_eq!((c.alloc_photo_id().0, c.alloc_photo_id().0), (u64::MAX, u64::MAX), "saturates");
|
||||
let p = Photo::new(PhotoId(u64::MAX), Source::Demo { scene: 1 }, "a", "JPEG", 1, 1, "t");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
|
||||
assert!(c.apply(Op::AddPhoto { photo: Box::new(p) }).is_err(), "the id is taken: refused, not overwritten");
|
||||
}
|
||||
|
||||
/// A sound catalog loads unchanged.
|
||||
#[test]
|
||||
fn a_sound_snapshot_needs_no_repair() {
|
||||
let (c, _) = sound();
|
||||
let (loaded, report) = load_edited(&c, |_| {});
|
||||
assert!(report.repaired.is_empty(), "{:?}", report.repaired);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// Found by fuzzing: an album filed under one id but naming another (here: the folder's) put
|
||||
/// itself inside itself, and listing the folder tree overflowed the stack (an abort, which no
|
||||
/// panic guard catches, every time the library opened).
|
||||
#[test]
|
||||
fn an_album_filed_under_another_id_gets_its_own() {
|
||||
let (c, _) = sound();
|
||||
let folder = c.albums().find(|a| a.folder).unwrap().id;
|
||||
let inner = c.albums().find(|a| !a.folder).unwrap().id;
|
||||
let (c, report) = load_edited(&c, |v| v["albums"][inner.0.to_string()]["id"] = folder.0.into());
|
||||
assert!(report.repaired.iter().any(|r| r.contains(&format!("album {} was filed as {}", folder.0, inner.0))), "{:?}", report.repaired);
|
||||
let a = c.album(inner).unwrap();
|
||||
assert_eq!((a.id, a.parent), (inner, Some(folder)));
|
||||
assert_eq!(c.album_children(Some(folder)).iter().map(|a| a.id).collect::<Vec<_>>(), vec![inner]);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ let fmt: Option<Format> = sniff(&bytes); // JPEG/PNG/TIFF/WebP/A
|
||||
let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
// d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace)
|
||||
// d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs)
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford), alpha flattened onto white
|
||||
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
|
||||
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
|
||||
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
|
||||
@@ -53,7 +53,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) |
|
||||
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
|
||||
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
|
||||
|
||||
|
||||
@@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 {
|
||||
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
|
||||
}
|
||||
|
||||
/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum HdrTransfer {
|
||||
#[default]
|
||||
Pq,
|
||||
Hlg,
|
||||
}
|
||||
|
||||
/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408).
|
||||
pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0;
|
||||
|
||||
/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100:
|
||||
/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma
|
||||
/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75,
|
||||
/// as BT.2408 asks. Non-finite or negative inputs count as 0.
|
||||
pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] {
|
||||
const K: [f32; 3] = [0.2627, 0.6780, 0.0593];
|
||||
const GAMMA: f32 = 1.2;
|
||||
let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS));
|
||||
let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0);
|
||||
let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 };
|
||||
fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]).
|
||||
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq)
|
||||
}
|
||||
|
||||
/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
|
||||
/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
|
||||
/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
|
||||
/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level
|
||||
/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display).
|
||||
/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
|
||||
pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
|
||||
@@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
|
||||
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
|
||||
sum_nits += y_nits as f64;
|
||||
let [r, g, b] = nits.map(pq_encode);
|
||||
let [r, g, b] = match transfer {
|
||||
HdrTransfer::Pq => nits.map(pq_encode),
|
||||
HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]),
|
||||
};
|
||||
let y = K[0] * r + K[1] * g + K[2] * b;
|
||||
let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
|
||||
let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
|
||||
@@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
ec.pixel_range = PixelRange::Full;
|
||||
ec.color_description = Some(ColorDescription {
|
||||
color_primaries: ColorPrimaries::BT2020,
|
||||
transfer_characteristics: TransferCharacteristics::SMPTE2084,
|
||||
transfer_characteristics: match transfer {
|
||||
HdrTransfer::Pq => TransferCharacteristics::SMPTE2084,
|
||||
HdrTransfer::Hlg => TransferCharacteristics::HLG,
|
||||
},
|
||||
matrix_coefficients: MatrixCoefficients::BT2020NCL,
|
||||
});
|
||||
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
|
||||
ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
|
||||
ec.content_light =
|
||||
(transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
|
||||
ec.still_picture = true;
|
||||
ec.quantizer = quantizer;
|
||||
ec.min_quantizer = quantizer.min(255) as u8;
|
||||
@@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
use avif_serialize::constants as c;
|
||||
let mut a = avif_serialize::Aviffy::new();
|
||||
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
|
||||
.set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
|
||||
.set_transfer_characteristics(match transfer {
|
||||
HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084,
|
||||
HdrTransfer::Hlg => c::TransferCharacteristics::Hlg,
|
||||
})
|
||||
.set_color_primaries(c::ColorPrimaries::Bt2020)
|
||||
.set_full_color_range(true)
|
||||
.set_content_light_level(cll.0, cll.1);
|
||||
.set_full_color_range(true);
|
||||
if transfer == HdrTransfer::Pq {
|
||||
a.set_content_light_level(cll.0, cll.1);
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
a.set_exif(exif.to_vec());
|
||||
}
|
||||
@@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed:
|
||||
}
|
||||
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (quality, speed, meta);
|
||||
let _ = (quality, speed, meta, transfer);
|
||||
Err(Error::Encode("AVIF encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
@@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<
|
||||
.map_err(|e| Error::Encode(e.to_string()))?;
|
||||
Ok(r.avif_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hlg_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hlg_reference_white_and_peak() {
|
||||
// BT.2408: SDR (HDR reference) white sits at 75 % HLG signal
|
||||
for v in hlg_signal([1.0; 3]) {
|
||||
assert!((v - 0.75).abs() < 0.005, "{v}");
|
||||
}
|
||||
// the 1000 cd/m² peak (≈ 4.9× SDR white) reaches full signal; above it clips
|
||||
let peak = HLG_REFERENCE_PEAK_NITS / HDR_REFERENCE_WHITE_NITS;
|
||||
assert!(hlg_signal([peak; 3]).iter().all(|v| (v - 1.0).abs() < 1e-3));
|
||||
assert_eq!(hlg_signal([100.0; 3]), [1.0; 3]);
|
||||
// black, negatives and non-finite values are 0, never NaN
|
||||
assert_eq!(hlg_signal([0.0, -1.0, f32::NAN]), [0.0; 3]);
|
||||
assert!(hlg_signal([f32::INFINITY, 0.0, 0.0]).iter().all(|v| v.is_finite()));
|
||||
// monotonic in brightness
|
||||
let mut prev = 0.0;
|
||||
for i in 1..50 {
|
||||
let v = hlg_signal([i as f32 * 0.1; 3])[1];
|
||||
assert!(v >= prev);
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_avif_rejects_short_buffers() {
|
||||
let m = EncodeMeta::default();
|
||||
assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err());
|
||||
assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err());
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,8 @@ mod tiff_codec;
|
||||
mod webp;
|
||||
|
||||
pub use encode::{
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
|
||||
encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless,
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression,
|
||||
encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal,
|
||||
};
|
||||
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
|
||||
pub use sniff::{Format, sniff};
|
||||
@@ -244,12 +244,24 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
||||
}
|
||||
|
||||
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
|
||||
///
|
||||
/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest
|
||||
/// of the pipeline (issue #1212).
|
||||
pub fn to_working(d: &Decoded) -> Rgb32f {
|
||||
if d.space.is_working() {
|
||||
return d.image.clone();
|
||||
let mut img = if d.space.is_working() {
|
||||
d.image.clone()
|
||||
} else {
|
||||
let m = d.space.to_working().to_f32();
|
||||
d.image.map(|p| mat_apply(&m, p))
|
||||
};
|
||||
// white is (1, 1, 1) in the working space too: the conversion is white-preserving
|
||||
if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) {
|
||||
for (p, &av) in img.data.iter_mut().zip(&a.data) {
|
||||
let av = av.clamp(0.0, 1.0);
|
||||
*p = p.map(|c| c * av + (1.0 - av));
|
||||
}
|
||||
}
|
||||
let m = d.space.to_working().to_f32();
|
||||
d.image.map(|p| mat_apply(&m, p))
|
||||
img
|
||||
}
|
||||
|
||||
/// Where a thumbnail came from.
|
||||
|
||||
@@ -140,6 +140,24 @@ fn png_metadata_and_gray_alpha() {
|
||||
assert!((a - 128.0 / 255.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212:
|
||||
/// transparent areas, stored black, rendered black).
|
||||
#[test]
|
||||
fn png_transparency_develops_on_white() {
|
||||
// opaque red, transparent black (as most encoders store it), half-transparent red
|
||||
let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128];
|
||||
let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap();
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert!(d.has_alpha);
|
||||
let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap();
|
||||
let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0);
|
||||
let w = d.to_working();
|
||||
let a = 128.0 / 255.0;
|
||||
for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] {
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p3_icc_to_working() {
|
||||
let icc = write_named(NamedSpace::DisplayP3);
|
||||
|
||||
@@ -5,3 +5,8 @@ No weights or inference backend. See [the user workflow](../../docs/denoise.md).
|
||||
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
|
||||
crate directly when inference is disabled. The default download catalog is empty.
|
||||
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.
|
||||
|
||||
The Bayer runner rejects non-finite source samples before inference and non-finite
|
||||
results after gain matching, highlight protection and tile blending. Finite negative
|
||||
and HDR source samples remain supported. These guards do not implement Raw Details
|
||||
or Super Resolution.
|
||||
@@ -95,6 +95,9 @@ pub fn denoise_bayer(
|
||||
if mosaic.len() != pixels {
|
||||
return Err(Error::Input("the mosaic does not have width × height samples".into()));
|
||||
}
|
||||
if mosaic.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Input("the mosaic has samples that are not finite".into()));
|
||||
}
|
||||
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
|
||||
return Err(Error::Input("the tile size and overlap do not fit".into()));
|
||||
}
|
||||
@@ -148,6 +151,9 @@ pub fn denoise_bayer(
|
||||
return Err(Error::Model("numbers that are not finite".into()));
|
||||
}
|
||||
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
|
||||
if out.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Model("numbers that are not finite after scale matching or highlight protection".into()));
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.collect()
|
||||
@@ -201,6 +207,9 @@ pub fn denoise_bayer(
|
||||
}
|
||||
}
|
||||
});
|
||||
if acc.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Model("numbers that are not finite after tile blending".into()));
|
||||
}
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
eprintln!(
|
||||
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
|
||||
@@ -388,6 +397,53 @@ mod tests {
|
||||
Params { tile, overlap, gain, clip: None, parallel: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonfinite_mosaics_are_rejected_before_inference() {
|
||||
struct Counting(std::sync::atomic::AtomicUsize);
|
||||
impl TileRunner for Counting {
|
||||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(vec![0.0; 3 * 32 * 32])
|
||||
}
|
||||
}
|
||||
let model = Counting(std::sync::atomic::AtomicUsize::new(0));
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
for sample in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert!(matches!(denoise_bayer(&[sample], 1, 1, layout, &model, ¶ms(16, 0, Gain::None), &Control::default()), Err(Error::Input(_))));
|
||||
}
|
||||
assert_eq!(model.0.load(Ordering::Relaxed), 0);
|
||||
// Finite scene-linear samples can be negative or exceed SDR white.
|
||||
for sample in [-0.25, 2.0] {
|
||||
assert!(denoise_bayer(&[sample], 1, 1, layout, &model, ¶ms(16, 0, Gain::None), &Control::default()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_model_output_that_overflows_during_gain_matching_is_rejected() {
|
||||
struct Extreme;
|
||||
impl TileRunner for Extreme {
|
||||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(vec![f32::MAX; 3 * 32 * 32])
|
||||
}
|
||||
}
|
||||
let p = params(16, 0, Gain::MatchMean { nominal: 1e-6, max_deviation: 0.0 });
|
||||
assert!(matches!(denoise_bayer(&[0.5], 1, 1, Layout { dx: 0, dy: 0 }, &Extreme, &p, &Control::default()), Err(Error::Model(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_finite_tiles_that_overflow_the_accumulator_are_rejected() {
|
||||
struct Extreme;
|
||||
impl TileRunner for Extreme {
|
||||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(vec![f32::MAX; 3 * 32 * 32])
|
||||
}
|
||||
}
|
||||
assert!(matches!(
|
||||
denoise_bayer(&vec![0.5; 48 * 48], 48, 48, Layout { dx: 0, dy: 0 }, &Extreme, ¶ms(16, 4, Gain::None), &Control::default()),
|
||||
Err(Error::Model(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
|
||||
@@ -97,7 +97,12 @@ fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
|
||||
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
|
||||
return invalid("malformed LeakyRelu");
|
||||
}
|
||||
let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
|
||||
// only an omitted alpha gets the ONNX default; one that is not a single float must not read as 0
|
||||
let alpha = match next.attribute.as_slice() {
|
||||
[] => 0.01,
|
||||
[AttributeProto { f: Some(f), kind: 0 | 1, .. }] => *f,
|
||||
_ => return invalid("LeakyRelu alpha is not a single float"),
|
||||
};
|
||||
i += 1;
|
||||
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
|
||||
return Ok((Some(alpha), name));
|
||||
@@ -319,6 +324,25 @@ mod tests {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leaky_relu_alpha_that_is_not_a_float_is_rejected() {
|
||||
let good = unet_onnx(4, 3, 1, 7);
|
||||
assert!(read_bytes(&good).is_ok());
|
||||
// an alpha attribute: float field 2, then the declared type (field 20) FLOAT
|
||||
let mut alpha = vec![0x15];
|
||||
alpha.extend_from_slice(&0.2f32.to_le_bytes());
|
||||
let at = good.windows(alpha.len()).position(|w| w == alpha.as_slice()).unwrap() + alpha.len();
|
||||
assert_eq!(good[at..at + 3], [0xa0, 0x01, 1]);
|
||||
// declared INT: the float must not be taken as the slope
|
||||
let mut wrong_type = good.clone();
|
||||
wrong_type[at + 2] = 2;
|
||||
assert!(matches!(read_bytes(&wrong_type), Err(NetError::Invalid(_))));
|
||||
// declared FLOAT but no float value (the float field becomes an int field 3 = 0 of the same length): must not read as 0
|
||||
let mut no_float = good.clone();
|
||||
no_float[at - 5..at].copy_from_slice(&[0x18, 0x80, 0x80, 0x80, 0x00]);
|
||||
assert!(matches!(read_bytes(&no_float), Err(NetError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
|
||||
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
|
||||
|
||||
@@ -113,7 +113,10 @@ fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
|
||||
#[derive(Default)]
|
||||
pub(super) struct AttributeProto {
|
||||
pub name: String,
|
||||
pub f: f32,
|
||||
/// `None` when the file has no float value for the attribute.
|
||||
pub f: Option<f32>,
|
||||
/// The declared attribute type (1 float, 2 int, 3 string, 7 ints); 0 when the file does not say.
|
||||
pub kind: i64,
|
||||
pub i: i64,
|
||||
pub s: Vec<u8>,
|
||||
pub ints: Vec<i64>,
|
||||
@@ -151,7 +154,7 @@ fn attribute(b: &[u8]) -> Result<AttributeProto> {
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => a.name = string(b)?,
|
||||
(2, Wire::F32(v)) => a.f = v,
|
||||
(2, Wire::F32(v)) if a.f.is_none() => a.f = Some(v),
|
||||
(3, Wire::Int(v)) => a.i = v as i64,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_STRING {
|
||||
@@ -160,7 +163,7 @@ fn attribute(b: &[u8]) -> Result<AttributeProto> {
|
||||
a.s = b.to_vec();
|
||||
}
|
||||
(8, w) => ints(w, &mut a.ints, 16)?,
|
||||
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
|
||||
(20, Wire::Int(v @ (1 | 2 | 3 | 7))) if a.kind == 0 => a.kind = v as i64,
|
||||
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,45 @@ impl ControlSpec {
|
||||
let s = format!("{:.*}", self.decimals as usize, v);
|
||||
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
|
||||
}
|
||||
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
|
||||
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
|
||||
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
|
||||
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
|
||||
/// linear.
|
||||
pub fn position(&self, v: f64) -> f64 {
|
||||
let v = self.clamp(v);
|
||||
let p = match self.kelvin_scale() {
|
||||
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
|
||||
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
|
||||
None => (v - self.min) / (self.max - self.min).max(1e-9),
|
||||
};
|
||||
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
|
||||
pub fn value_at(&self, p: f64) -> f64 {
|
||||
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let v = match self.kelvin_scale() {
|
||||
Some(_) if p >= 1.0 => self.max,
|
||||
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
|
||||
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
|
||||
None => self.min + p * (self.max - self.min),
|
||||
};
|
||||
self.clamp(v)
|
||||
}
|
||||
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
|
||||
/// `None` for every other control, or a range the centre isn't inside.
|
||||
fn kelvin_scale(&self) -> Option<(f64, f64)> {
|
||||
let centre = KELVIN_CENTRE;
|
||||
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The Kelvin temperature at the middle of its track, as in Lightroom.
|
||||
const KELVIN_CENTRE: f64 = 7000.0;
|
||||
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
|
||||
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
|
||||
const KELVIN_KNEE: f64 = 1000.0;
|
||||
|
||||
macro_rules! controls {
|
||||
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
|
||||
/// All numeric develop controls, in panel order.
|
||||
@@ -508,4 +545,46 @@ mod tests {
|
||||
assert_eq!(e.format(-1.0), "-1.00");
|
||||
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
|
||||
}
|
||||
|
||||
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
|
||||
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
|
||||
#[test]
|
||||
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
|
||||
let temp = find("wb.temp").unwrap();
|
||||
for (k, lightroom, model) in [
|
||||
(2000.0, 0.0, 0.0),
|
||||
(3650.0, 0.167, 0.165),
|
||||
(5400.0, 0.333, 0.34),
|
||||
(7000.0, 0.5, 0.5),
|
||||
(15000.0, 0.79, 0.79),
|
||||
(20000.0, 0.853, 0.849),
|
||||
(30000.0, 0.90, 0.92),
|
||||
(50000.0, 1.0, 1.0),
|
||||
] {
|
||||
let p = temp.position(k);
|
||||
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
|
||||
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
|
||||
}
|
||||
// every 50 K step survives value → position → value, and the track never runs backwards
|
||||
let mut last = -1.0;
|
||||
for k in (2000..=50000).step_by(50).map(f64::from) {
|
||||
let p = temp.position(k);
|
||||
assert!(p > last, "{k} K");
|
||||
last = p;
|
||||
let back = (temp.value_at(p) / temp.step).round() * temp.step;
|
||||
assert_eq!(back, k, "{k} K → {p} → {back}");
|
||||
}
|
||||
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
|
||||
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
|
||||
let exposure = find("light.exposure").unwrap();
|
||||
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
|
||||
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
|
||||
}
|
||||
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
|
||||
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
|
||||
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
|
||||
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
|
||||
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
|
||||
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! UI-thread work one slider-drag tick costs outside the render: `develop.set` inside an
|
||||
//! interaction, the filmstrip's `visible()` after the catalog revision moved, and the settings
|
||||
//! hash the loupe takes every frame. `cargo run --release -p lightcraft-engine --example
|
||||
//! drag_tick_bench -- [photos]` (default 20000).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use lightcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let n: u64 = std::env::args().nth(1).and_then(|a| a.parse().ok()).unwrap_or(20_000);
|
||||
let mut s = Session::new();
|
||||
for i in 1..=n {
|
||||
let mut p = Photo::new(PhotoId(i), Source::File { path: format!("/x/{i}.ARW") }, &format!("{i}.ARW"), "ARW", 9984, 6656, "2026-01-01");
|
||||
p.captured = Some(format!("2025-{:02}-{:02}T10:{:02}:00", 1 + i % 12, 1 + i % 28, i % 60));
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
s.execute("library.select", &json!({"ids": [1]})).unwrap();
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
|
||||
s.visible_cloned();
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
|
||||
let ticks = 30;
|
||||
let (mut set, mut vis, mut hash) = (0.0, 0.0, 0.0);
|
||||
for k in 0..ticks {
|
||||
let t = Instant::now();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": k as f64 / 30.0})).unwrap();
|
||||
set += ms(t);
|
||||
let t = Instant::now();
|
||||
let v = s.visible_cloned();
|
||||
vis += ms(t);
|
||||
assert_eq!(v.len() as u64, n);
|
||||
let d = s.develop_of(PhotoId(1)).unwrap();
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(d.hash64());
|
||||
hash += ms(t);
|
||||
}
|
||||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||
let f = ticks as f64;
|
||||
println!("{n} photos, per tick: develop.set {:.3} ms, visible() {:.3} ms, settings hash64 {:.3} ms", set / f, vis / f, hash / f);
|
||||
}
|
||||
@@ -9,7 +9,15 @@
|
||||
//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU
|
||||
//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores.
|
||||
//! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `lightcraft-gpu`
|
||||
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only).
|
||||
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only, `GPU_ONLY=1` for the GPU only).
|
||||
//!
|
||||
//! `ONLY=window`: a 1:1 zoom window cut from the full-size original (the loupe's `Slot::Region`):
|
||||
//! the first window of a photo, a pan, and exposure / draft / NR changes with a warm stage cache;
|
||||
//! `DUMP=file` writes the GPU window's RGBA bytes, for pixel A/B comparisons between builds.
|
||||
//! Opening a photo, per file: `ONLY=source` (decode steps), `ONLY=load` (what the app's loaders
|
||||
//! cost: file read, decode, binning, embedded stand-ins, `load_bytes(2560)`, the fs loader),
|
||||
//! `ONLY=load1` (decode checksum + `load_bytes(2560)`; `LOADONLY=1`: only that, for a profiler),
|
||||
//! `ONLY=jpeg` (a JPEG decoded to fit 256–2560 px boxes).
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -88,6 +96,63 @@ fn main() {
|
||||
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||
let only = std::env::var("ONLY").unwrap_or_default();
|
||||
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
|
||||
if only == "jpeg" {
|
||||
// a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
for edge in [256u32, 512, 1600, 2560] {
|
||||
let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
|
||||
let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
|
||||
println!(
|
||||
" {path} fit {edge}: {}×{} {}",
|
||||
d.image.width,
|
||||
d.image.height,
|
||||
best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if only == "load" || only == "load1" {
|
||||
// opening a photo in the loupe, step by step: what the app's loaders cost (per file)
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
if std::env::var("LOADONLY").is_ok() {
|
||||
// only the preview load (for a profiler)
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
continue;
|
||||
}
|
||||
println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
if let lightcraft_raw::RawData::U16(d) = &raw.data {
|
||||
let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
|
||||
println!(" decode checksum: {sum:016x}");
|
||||
}
|
||||
if only == "load1" {
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
continue;
|
||||
}
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||||
println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
|
||||
}
|
||||
for edge in [2560usize, 1600] {
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
format!("embedded srgb, whole file ({edge}):"),
|
||||
best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
|
||||
);
|
||||
let quick = lightcraft_engine::files::fs_preview_loader();
|
||||
println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
|
||||
}
|
||||
println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
|
||||
let (loader, _) = lightcraft_engine::files::fs_hooks();
|
||||
println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if only == "source" {
|
||||
// opening a photo: where the time goes before the first sharp loupe (per file)
|
||||
for path in &args {
|
||||
@@ -152,10 +217,16 @@ fn main() {
|
||||
Some(a) => println!("gpu: {a} (device + kernels: {:.0} ms)", t.elapsed().as_secs_f64() * 1e3),
|
||||
None => println!("gpu: none"),
|
||||
}
|
||||
let backends: &[bool] = if gpu { &[false, true] } else { &[false] };
|
||||
// `GPU_ONLY=1`: skip the CPU column (profiling the GPU path)
|
||||
let backends: &[bool] = match (gpu, std::env::var_os("GPU_ONLY").is_some()) {
|
||||
(true, true) => &[true],
|
||||
(true, false) => &[false, true],
|
||||
_ => &[false],
|
||||
};
|
||||
let row = |name: &str, f: &mut dyn FnMut(bool)| {
|
||||
let cols: Vec<String> = backends.iter().map(|&g| format!("{}", best(n, || f(g)))).collect();
|
||||
println!("{name:<38} cpu: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
|
||||
let first = if backends[0] { "gpu" } else { "cpu" };
|
||||
println!("{name:<38} {first}: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
|
||||
};
|
||||
let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box));
|
||||
let s = typical();
|
||||
@@ -214,6 +285,68 @@ fn main() {
|
||||
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
|
||||
});
|
||||
}
|
||||
if run("window") {
|
||||
// a 1:1 zoom window (the loupe's `Slot::Region`): visible 1920×1200 + margin, snapped to the
|
||||
// 256 px grid, cut from the full-resolution original (`ui-egui` `region::window_for`)
|
||||
use lightcraft_pipeline::PixelWindow;
|
||||
let (fw, fh) = (full.width, full.height);
|
||||
let win = |cx: usize, cy: usize| {
|
||||
let snap = |v: usize| v / 256 * 256;
|
||||
let (x, y) = (snap(cx.saturating_sub(960 + 512)), snap(cy.saturating_sub(600 + 512)));
|
||||
PixelWindow { x, y, w: 3072.min(fw - x), h: 2304.min(fh - y) }
|
||||
};
|
||||
let at = |w: PixelWindow, q: Quality| RenderRequest { window: Some(w), quality: q, ..RenderRequest::fit(fw, fh) };
|
||||
let w0 = win(fw / 2, fh / 2);
|
||||
println!("window {}×{} at ({}, {}) of {fw}×{fh}", w0.w, w0.h, w0.x, w0.y);
|
||||
// first window of a photo: nothing cached (the GPU uploads the original: `shared_source`)
|
||||
row("window first (upload original):", &mut |g| {
|
||||
lightcraft_engine::gpu::trim_pool(0);
|
||||
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), g))
|
||||
});
|
||||
let wc = [StageCache::default(), StageCache::default()];
|
||||
for g in backends {
|
||||
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
|
||||
}
|
||||
let mut step = 0usize;
|
||||
row("window pan (new window, warm src):", &mut |g| {
|
||||
step += 1;
|
||||
let w = win(fw / 2 + 512 * (step % 4 + 1), fh / 2);
|
||||
drop(develop(&full, &info, &s, &at(w, Quality::Full), Some(&wc[g as usize]), g))
|
||||
});
|
||||
for g in backends {
|
||||
drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
|
||||
}
|
||||
row("window exposure change (warm):", &mut |g| {
|
||||
let mut t = s.clone();
|
||||
k += 1.0;
|
||||
t.light.exposure = 0.3 + k * 0.01;
|
||||
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
|
||||
});
|
||||
row("window draft exposure drag (warm):", &mut |g| {
|
||||
let mut t = s.clone();
|
||||
k += 1.0;
|
||||
t.light.exposure = 0.3 + k * 0.01;
|
||||
drop(develop(&full, &info, &t, &at(w0, Quality::Draft), Some(&wc[g as usize]), g));
|
||||
});
|
||||
row("window NR change (warm):", &mut |g| {
|
||||
let mut t = s.clone();
|
||||
k += 1.0;
|
||||
t.detail.nr_luminance = 30.0 + k % 50.0;
|
||||
drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
|
||||
});
|
||||
// `DUMP=file`: the GPU window's raw RGBA bytes (A/B pixel comparisons between builds)
|
||||
if let (true, Some(path)) = (gpu, std::env::var_os("DUMP")) {
|
||||
let g = develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), true).image;
|
||||
let bytes: Vec<u8> = g.data.iter().flatten().copied().collect();
|
||||
std::fs::write(path, bytes).expect("dump");
|
||||
}
|
||||
if gpu && std::env::var_os("GPU_ONLY").is_none() {
|
||||
let c = develop(&full, &info, &s, &at(w0, Quality::Full), None, false).image;
|
||||
let g = develop(&full, &info, &s, &at(w0, Quality::Full), None, true).image;
|
||||
let (max, mean) = diff(&c, &g);
|
||||
println!("gpu vs cpu, window: max {max} LSB, mean {mean:.4} LSB");
|
||||
}
|
||||
}
|
||||
if run("finish") {
|
||||
// per-pixel stage cost by feature (warm cache: exposure changes only)
|
||||
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! What a scan, an import and Synchronize Folder cost before they read the disk, and what
|
||||
//! naming one photo's sidecar costs, by the size of the library (`N`, default 100,000 photos; the
|
||||
//! folder looked at holds 500 of them).
|
||||
//!
|
||||
//! `N=3500000 cargo run --release -p lightcraft-engine --example scan_setup_bench`
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
fn main() {
|
||||
let n: u64 = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000);
|
||||
let mut s = Session::new();
|
||||
let t = Instant::now();
|
||||
for chunk in (0..n).collect::<Vec<_>>().chunks(10_000) {
|
||||
for i in chunk {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = format!("IMG_{i:08}.jpg");
|
||||
let path = format!("/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day {:05}/extra/{name}", i / 500);
|
||||
let mut p = Photo::new(id, Source::File { path }, &name, "JPEG", 4032, 3024, "2026-09-30T12:00:00");
|
||||
p.file_size = 3_400_000 + i;
|
||||
p.captured = Some(format!("2019-07-{: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)));
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
}
|
||||
eprintln!("{n} photos built in {:.2} s", t.elapsed().as_secs_f64());
|
||||
let folder = "/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day 00000".to_string();
|
||||
for run in 0..3 {
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
|
||||
let t = Instant::now();
|
||||
let f = lightcraft_catalog::Filter { library_folder: Some(folder.clone()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &lightcraft_catalog::Sort::default());
|
||||
let query = ms(t);
|
||||
let t = Instant::now();
|
||||
let namings = s.sidecar_namings(&ids);
|
||||
let naming = ms(t);
|
||||
let t = Instant::now();
|
||||
let one = ids.first().map(|id| s.sidecar_naming(*id));
|
||||
let naming_one = ms(t);
|
||||
let t = Instant::now();
|
||||
let (input, paths) = lightcraft_engine::import::ScanInput::new(&mut s, std::slice::from_ref(&folder));
|
||||
let scan = ms(t);
|
||||
// (the folder is not on this disk: the scan finds no file, and its cost is what it makes of the library)
|
||||
let t = Instant::now();
|
||||
let out = lightcraft_engine::import::scan_with(input, &paths, &Default::default());
|
||||
let scanned = ms(t);
|
||||
let t = Instant::now();
|
||||
let job = lightcraft_engine::import::ImportJob::new(&mut s, Default::default());
|
||||
let import = ms(t);
|
||||
let t = Instant::now();
|
||||
let sync = lightcraft_engine::sync::SyncInput::new(&mut s, &folder, false);
|
||||
let whole = ms(t);
|
||||
println!(
|
||||
"N={n} run {run}: query {query:.0} ms ({} ids) | sidecar_namings {naming:.0} ms | sidecar_naming of one {naming_one:.0} ms ({one:?}) | ScanInput::new {scan:.0} ms + scan_with {scanned:.0} ms ({} found) | ImportJob::new {import:.0} ms (ok {}) | SyncInput::new {whole:.0} ms (ok {})",
|
||||
ids.len(),
|
||||
out.candidates.len(),
|
||||
job.is_ok(),
|
||||
sync.is_ok()
|
||||
);
|
||||
let _ = namings;
|
||||
}
|
||||
}
|
||||
@@ -308,9 +308,12 @@ pub enum Outcome {
|
||||
Imported(Imported),
|
||||
/// Photos (or osascript) failed: why, for the user.
|
||||
Failed(String),
|
||||
/// Photos wasn't asked: the export that reserved the import was cancelled, wrote no files or
|
||||
/// stopped early.
|
||||
Skipped(String),
|
||||
/// Photos wasn't asked: the export that reserved the import was cancelled (`cancelled`), wrote
|
||||
/// no files or stopped early. `why` is for people; code decides on `cancelled`.
|
||||
Skipped {
|
||||
why: String,
|
||||
cancelled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One import: reserved by an export still writing its files, running, or finished.
|
||||
@@ -402,9 +405,12 @@ impl ImportJob {
|
||||
v["running"] = json!(false);
|
||||
v["error"] = json!(e);
|
||||
}
|
||||
Some(Outcome::Skipped(why)) => {
|
||||
Some(Outcome::Skipped { why, cancelled }) => {
|
||||
v["running"] = json!(false);
|
||||
v["skipped"] = json!(why);
|
||||
if cancelled {
|
||||
v["cancelled"] = json!(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
@@ -660,7 +666,7 @@ impl Reservation {
|
||||
None
|
||||
};
|
||||
if let Some(why) = skipped {
|
||||
job.finish(Outcome::Skipped(why.into()));
|
||||
job.finish(Outcome::Skipped { why: why.into(), cancelled });
|
||||
return Err(job.json());
|
||||
}
|
||||
if let Err(e) = check(&paths, &self.album) {
|
||||
@@ -700,7 +706,7 @@ impl Reservation {
|
||||
impl Drop for Reservation {
|
||||
fn drop(&mut self) {
|
||||
if let Some(job) = self.job.take() {
|
||||
job.finish(Outcome::Skipped("the export ended before writing its files".into()));
|
||||
job.finish(Outcome::Skipped { why: "the export ended before writing its files".into(), cancelled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,8 +1015,10 @@ mod tests {
|
||||
// nothing written, or cancelled: Photos isn't asked, and the slot is free again
|
||||
let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false);
|
||||
assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}");
|
||||
assert_eq!(out.get("cancelled"), None, "{out}");
|
||||
let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true);
|
||||
assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}");
|
||||
assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}");
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
assert!(imports.running().is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
@@ -10,7 +10,11 @@ use lightcraft_raster::{
|
||||
Rgb32f,
|
||||
resample::{Filter, fit, resize},
|
||||
};
|
||||
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
|
||||
use lightcraft_raw::{
|
||||
ColorData, RawFormat, RawImage,
|
||||
color::CameraTransform,
|
||||
profile::{HsvTable, ProfileLook, ToneCurve},
|
||||
};
|
||||
|
||||
/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone
|
||||
/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because
|
||||
@@ -25,7 +29,8 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa
|
||||
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
|
||||
/// states, for every make (#778).
|
||||
/// 4: a tone curve from black-and-white camera JPEGs (#577).
|
||||
pub const LOOK_VERSION: u32 = 4;
|
||||
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
|
||||
pub const LOOK_VERSION: u32 = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
@@ -107,6 +112,69 @@ fn starting_colour_with(
|
||||
(xy, t, look)
|
||||
}
|
||||
|
||||
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
|
||||
/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered
|
||||
/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them).
|
||||
pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<ColorData> {
|
||||
if lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
return None;
|
||||
}
|
||||
let (_, t, look) = starting_colour(raw, bytes);
|
||||
dng_colour(&raw.color, &t, look.as_ref())
|
||||
}
|
||||
|
||||
/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward
|
||||
/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation
|
||||
/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table
|
||||
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
|
||||
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
|
||||
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
|
||||
/// `None` when the white balance or the matrix can't give tags.
|
||||
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
|
||||
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
|
||||
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
|
||||
// into the baseline exposure
|
||||
let white = m.apply([1.0; 3]);
|
||||
let k = (white[0] + white[1] + white[2]) / 3.0;
|
||||
if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let m = Mat3(m.0.map(|r| r.map(|v| v / k)));
|
||||
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
|
||||
let wb = t.wb.map(f64::from);
|
||||
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
|
||||
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
|
||||
Some(ColorData {
|
||||
illuminant: [21, 0],
|
||||
color_matrix: [Some(camera_to_xyz.inverse()?), None],
|
||||
forward_matrix: [Some(forward), None],
|
||||
camera_calibration: [None, None],
|
||||
analog_balance: None,
|
||||
as_shot_neutral: Some(neutral),
|
||||
as_shot_white_xy: None,
|
||||
baseline_exposure: t.baseline_exposure + k.log2(),
|
||||
baseline_sharpness: tags.baseline_sharpness,
|
||||
kelvin_presets: None,
|
||||
profile: ProfileLook {
|
||||
hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None],
|
||||
tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
|
||||
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
|
||||
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
|
||||
const N: usize = 256;
|
||||
let samples = (0..=N).map(|i| {
|
||||
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32);
|
||||
[x, tone.apply(x).min(1.0)]
|
||||
});
|
||||
let flat: Vec<f64> = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect();
|
||||
ToneCurve::from_tag(&flat)
|
||||
}
|
||||
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
@@ -265,12 +333,20 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and
|
||||
/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px);
|
||||
/// 128 MP also admits 100 MP medium-format previews.
|
||||
const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
|
||||
/// and per proxy pixel whether any sensor sample it covers is clipped.
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
bytes,
|
||||
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
|
||||
None,
|
||||
)?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
@@ -569,7 +645,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
|
||||
if (0.015..=1.0).contains(&y) {
|
||||
bright.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
|
||||
if !colour_pair(*input, *output) {
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
@@ -580,6 +656,77 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
|
||||
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
|
||||
}
|
||||
|
||||
/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting.
|
||||
fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool {
|
||||
input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|
||||
&& output.iter().all(|v| *v > 0.004 && *v < 0.98)
|
||||
&& (0.015..0.85).contains(&luminance_2020(output))
|
||||
}
|
||||
|
||||
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
|
||||
/// The original pairs and their split still own the colour fit and its acceptance gates.
|
||||
/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and
|
||||
/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise.
|
||||
fn fit_tone_with_shadows(
|
||||
fit: ToneFit,
|
||||
pairs: &[([f64; 3], [f64; 3])],
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
edge_limit: Option<f32>,
|
||||
colour: impl Fn([f64; 3]) -> [f64; 3],
|
||||
) -> Option<CameraTone> {
|
||||
let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y));
|
||||
let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect());
|
||||
let mut eligible = Vec::new();
|
||||
let (mut colour_index, mut extra_index) = (0, 0);
|
||||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||||
continue;
|
||||
}
|
||||
// About one 8-bit sRGB code above black: don't learn a flat black segment from
|
||||
// JPEG compression. The sensor proxy averages many samples before this test.
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5)
|
||||
|| !output.iter().all(|v| v.is_finite() && *v >= 0.0)
|
||||
|| !(0.0003..0.85).contains(&luminance_2020(*output))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A colour holdout must never enter tone training just because the wider sample set
|
||||
// changes its index. Split the extra samples separately, keeping every colour index.
|
||||
let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index };
|
||||
let held = *index % 3 == 0;
|
||||
*index += 1;
|
||||
let (x, y) = luminances(input.map(f64::from), output.map(f64::from));
|
||||
if x.is_finite() && x > 0.0 {
|
||||
eligible.push((x, y, held));
|
||||
}
|
||||
}
|
||||
let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count();
|
||||
let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect();
|
||||
// Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank,
|
||||
// so a single quantized grey also keeps the original curve.
|
||||
if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION {
|
||||
return original;
|
||||
}
|
||||
let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect();
|
||||
let Some(candidate) = fit.fit(training) else { return original };
|
||||
let error = |tone: &CameraTone| -> f64 {
|
||||
held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum()
|
||||
};
|
||||
// A wider histogram must also help independent dark pixels, not just redistribute the
|
||||
// knots. The usual held-out gates still check the completed look on the colour pairs.
|
||||
if let Some(old) = original {
|
||||
let (before, after) = (error(&old), error(&candidate));
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}");
|
||||
}
|
||||
if !after.is_finite() || after >= before * 0.99 {
|
||||
return original;
|
||||
}
|
||||
}
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
|
||||
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||||
let mut gram = [[0.0; 3]; 3];
|
||||
@@ -705,9 +852,7 @@ fn fit_pairs_on(
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let Some(curve) = tone_fit.fit(tone_pairs) else { continue };
|
||||
let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue };
|
||||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
@@ -849,9 +994,8 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
|
||||
})),
|
||||
None => Mat3::IDENTITY,
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None };
|
||||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?;
|
||||
let mut look = CameraLook { matrix, tone, hue_sat: None };
|
||||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||||
look.tone = tone;
|
||||
}
|
||||
@@ -957,9 +1101,8 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() };
|
||||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
|
||||
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
|
||||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||||
@@ -1317,6 +1460,167 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn steep_toe_scene() -> (Rgb32f, Rgb32f) {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
// A broad scene with a steep camera toe below the old JPEG floor of 0.015.
|
||||
// Permute brightness so the held-out pixels span the same range independently.
|
||||
let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25;
|
||||
let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025];
|
||||
*src = rgb.map(|v| v * y / luminance_2020(rgb));
|
||||
let display = 3.0 * y * (y / (y + 0.02)).powi(2);
|
||||
*dst = src.map(|v| v * display / y);
|
||||
}
|
||||
(sensor, reference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_follows_the_jpegs_deep_shadows() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap();
|
||||
let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
|
||||
assert!(dark.len() > 256);
|
||||
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
|
||||
assert!(error < 0.001, "deep-shadow luminance error {error}");
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
let fit = |reference: &Rgb32f| {
|
||||
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap();
|
||||
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
|
||||
};
|
||||
let expected = fit(&reference);
|
||||
let mut changed = reference.clone();
|
||||
let mut index = 0;
|
||||
for (x, y) in sensor.data.iter().zip(&mut changed.data) {
|
||||
if colour_pair(*x, *y) {
|
||||
if index % 3 == 0 {
|
||||
*y = y.map(|v| v * 1.1);
|
||||
assert!(colour_pair(*x, *y));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(fit(&changed), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_toe_is_robust_to_shadow_noise() {
|
||||
let (sensor, clean) = steep_toe_scene();
|
||||
let mut noisy = clean.clone();
|
||||
for (i, p) in noisy.data.iter_mut().enumerate() {
|
||||
if luminance_2020(*p) < 0.015 {
|
||||
// Quantized JPEG levels with bounded noise and occasional compression outliers.
|
||||
let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004;
|
||||
*p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003);
|
||||
if i % 23 == 0 {
|
||||
*p = [0.014; 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap();
|
||||
let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
|
||||
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
|
||||
assert!(error < 0.001, "noisy shadow luminance error {error}");
|
||||
assert_eq!(look.tone.apply(0.0), 0.0);
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
for i in 1..1000 {
|
||||
assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_toe_requires_lower_independent_dark_error() {
|
||||
for holdout_matches_original in [true, false] {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 };
|
||||
*src = [y; 3];
|
||||
// Midtones teach the original slope of 3. Dark training teaches 0.3,
|
||||
// but the independent dark holdout can agree with either slope.
|
||||
let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 };
|
||||
*dst = [y * slope; 3];
|
||||
assert_eq!(colour_pair(*src, *dst), i >= 600);
|
||||
}
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap();
|
||||
let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap();
|
||||
// Both sample sets start at a multiple of three, so this split matches
|
||||
// the helper's separate colour and extra-sample indices.
|
||||
let candidate = fit_tone(
|
||||
sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 3 != 0)
|
||||
.map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from))))
|
||||
.collect(),
|
||||
)
|
||||
.unwrap();
|
||||
let held: Pairs =
|
||||
sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect();
|
||||
assert_eq!(held.len(), 200);
|
||||
assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y))));
|
||||
assert!(rank_correlation_ties(&held) > 0.99);
|
||||
let error = |tone: &CameraTone| {
|
||||
held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::<f64>()
|
||||
};
|
||||
assert_ne!(candidate, original);
|
||||
let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap();
|
||||
if holdout_matches_original {
|
||||
assert!(error(&candidate) > error(&original) + 0.1);
|
||||
assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve");
|
||||
} else {
|
||||
assert!(error(&candidate) < error(&original) * 0.99);
|
||||
assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate");
|
||||
}
|
||||
let mut previous = 0.0;
|
||||
assert_eq!(selected.apply(0.0), 0.0);
|
||||
for i in 0..10_000 {
|
||||
let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0));
|
||||
assert!(y.is_finite() && y >= previous && y <= 1.0);
|
||||
previous = y;
|
||||
}
|
||||
assert_eq!(selected.apply(f32::NAN), 0.0);
|
||||
assert_eq!(selected.apply(f32::INFINITY), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreliable_dark_pairs_keep_the_original_tone_and_colour() {
|
||||
let (sensor, reference) = steep_toe_scene();
|
||||
let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect();
|
||||
let mut excluded = sensor.clone();
|
||||
for &i in &dark {
|
||||
excluded.data[i] = [f32::NAN; 3];
|
||||
}
|
||||
let colour = Some((Mat3::IDENTITY, None));
|
||||
let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap();
|
||||
for case in 0..6 {
|
||||
let (mut x, mut y) = (sensor.clone(), reference.clone());
|
||||
for (j, &i) in dark.iter().enumerate() {
|
||||
match case {
|
||||
0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal
|
||||
1 => x.data[i] = [0.00001; 3], // below the sensor noise floor
|
||||
2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs
|
||||
3 => x.data[i] = [0.003; 3], // no scene luminance spread
|
||||
4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe
|
||||
5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let look = fit_pairs_with(&x, &y, colour.clone()).unwrap();
|
||||
assert_eq!(look.tone, original.tone, "case {case}");
|
||||
assert_eq!(look.matrix, original.matrix, "case {case}");
|
||||
assert!(look.hue_sat.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
|
||||
// An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
|
||||
@@ -1834,17 +2138,19 @@ mod tests {
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let own = lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some();
|
||||
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
|
||||
let measured = lightcraft_raw::spectral::find(make, model).is_some();
|
||||
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
|
||||
let before = raw.color.clone();
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes);
|
||||
let spectral = !own && fitted.is_none() && covered;
|
||||
let spectral = !own && fitted.is_none() && measured;
|
||||
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
|
||||
assert_eq!(raw.color != before, spectral, "{name}");
|
||||
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
|
||||
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
|
||||
// spectral matrices, whichever look it got (issue #730)
|
||||
// white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin,
|
||||
// through the spectral matrices or its presets, whichever look it got (issue #730)
|
||||
assert_eq!(info.relative_wb, !own && !covered, "{name}");
|
||||
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
|
||||
if covered && !own {
|
||||
@@ -2062,6 +2368,43 @@ mod tests {
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
|
||||
/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same
|
||||
/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation
|
||||
/// table and its tone curve; without a look, those of the camera transform.
|
||||
#[test]
|
||||
fn dng_colour_renders_like_the_raws_starting_colour() {
|
||||
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
|
||||
let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37));
|
||||
assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb);
|
||||
let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
|
||||
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
|
||||
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
|
||||
for look in [Some(&look), None] {
|
||||
let dng = dng_colour(&ColorData::default(), &t, look).unwrap();
|
||||
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
|
||||
for i in 0..3 {
|
||||
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
|
||||
}
|
||||
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
|
||||
for c in samples {
|
||||
let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c));
|
||||
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}");
|
||||
}
|
||||
assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone()));
|
||||
let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve);
|
||||
match look {
|
||||
Some(look) => {
|
||||
let tone = tone.expect("the look's tone curve");
|
||||
let worst =
|
||||
(0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max);
|
||||
// (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here)
|
||||
assert!(worst < 4e-3, "{worst}");
|
||||
}
|
||||
None => assert!(tone.is_none()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
|
||||
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
|
||||
CameraLook {
|
||||
@@ -2317,6 +2660,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sony ARWs without measured matrices read Kelvin from their white-balance presets
|
||||
/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and
|
||||
/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its
|
||||
/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files.
|
||||
#[test]
|
||||
fn corpus_arw_white_balance_in_kelvin_through_the_presets() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
};
|
||||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
|
||||
assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}");
|
||||
assert!(
|
||||
(info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0,
|
||||
"{name}: {} {}",
|
||||
info.as_shot_temp,
|
||||
info.as_shot_tint
|
||||
);
|
||||
let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default());
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
@@ -2810,4 +3183,20 @@ mod tests {
|
||||
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
|
||||
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
|
||||
}
|
||||
|
||||
/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted.
|
||||
#[test]
|
||||
fn corpus_arw6_full_frame_gets_a_camera_look() {
|
||||
const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) };
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/arw6-sony-ilce7rm6-ff-land-c.arw");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
}
|
||||
@@ -303,9 +303,20 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
|
||||
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
|
||||
}
|
||||
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
|
||||
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let sibling = src.with_file_name("denoise-model.json");
|
||||
let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else {
|
||||
let why = if sibling.is_file() {
|
||||
"denoise-model.json could not be read or is too large"
|
||||
} else if src.with_file_name("model.json").is_file() {
|
||||
// other apps ship their models with a model.json of their own (#1381)
|
||||
"No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \
|
||||
does not read: it needs its own denoise-model.json (see docs/denoise.md)"
|
||||
} else {
|
||||
"No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)"
|
||||
};
|
||||
return Err(bad(C, why));
|
||||
};
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
|
||||
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
|
||||
|
||||
@@ -26,8 +26,11 @@ fn section_param(p: &Value, c: &str) -> Result<Section> {
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
|
||||
}
|
||||
|
||||
fn groups_param(p: &Value) -> Option<Vec<SettingsGroup>> {
|
||||
p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
|
||||
fn groups_param(p: &Value, c: &str) -> Result<Option<Vec<SettingsGroup>>> {
|
||||
match p.get("groups") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(c, format!("invalid `groups`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
@@ -497,13 +500,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| {
|
||||
let ch = str_param(p, "channel").unwrap_or("master").to_string();
|
||||
let pts: Vec<Point> = p
|
||||
let mut pts: Vec<Point> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("develop.curve", "missing points"))?
|
||||
.iter()
|
||||
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?.clamp(0.0, 1.0), q.get(1)?.as_f64()?.clamp(0.0, 1.0))))
|
||||
.collect();
|
||||
// stored in the order the curve runs, as the editor draws and drags them
|
||||
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
edit(s, "develop.curve", "Tone Curve", |d| {
|
||||
match ch.as_str() {
|
||||
"master" | "rgb" => d.curve.master = pts,
|
||||
@@ -782,8 +787,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.copy")?;
|
||||
if let Some(g) = groups_param(p) {
|
||||
let c = "develop.copy";
|
||||
let id = active(s, c)?;
|
||||
if let Some(g) = groups_param(p, c)? {
|
||||
s.copy_groups = g;
|
||||
}
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
@@ -802,7 +808,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = "develop.pastePrevious";
|
||||
let prev =
|
||||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||||
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
|
||||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||||
let ops = ids
|
||||
@@ -823,8 +829,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
|
||||
has_clipboard,
|
||||
|s, p| {
|
||||
let c = "develop.paste";
|
||||
let mut clip = s.clipboard.clone().unwrap_or_default();
|
||||
if let Some(only) = groups_param(p) {
|
||||
if let Some(only) = groups_param(p, c)? {
|
||||
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
|
||||
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
|
||||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||||
@@ -849,8 +856,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{groups?} — copy the active photo's settings to all selected photos",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.sync")?;
|
||||
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
|
||||
let c = "develop.sync";
|
||||
let id = active(s, c)?;
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let partial = lightcraft_develop::extract_groups(&d, &groups);
|
||||
let ops = s
|
||||
@@ -979,10 +987,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
|
||||
let id = active(s, "preset.create")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
|
||||
let c = "preset.create";
|
||||
let id = active(s, c)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(c, "missing name"))?;
|
||||
let group = str_param(p, "group").unwrap_or("User Presets");
|
||||
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
|
||||
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
|
||||
@@ -1026,7 +1035,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = "preset.update";
|
||||
let id = active(s, c)?;
|
||||
let current = s.develop_of(id).unwrap_or_default().to_json();
|
||||
let groups = groups_param(p);
|
||||
let groups = groups_param(p, c)?;
|
||||
let pr = user_preset(s, p, c)?;
|
||||
pr.settings = match groups {
|
||||
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
|
||||
@@ -1079,15 +1088,47 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = active(s, "history.clear")?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }];
|
||||
s.commit("Clear History", Op::SetHistory { id, history })?;
|
||||
s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| {
|
||||
let id = active(s, "history.restore")?;
|
||||
let i = f64_req(p, "index", "history.restore")? as usize;
|
||||
let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?;
|
||||
s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?;
|
||||
ok()
|
||||
cmd!(
|
||||
"history.restore",
|
||||
"Go to History Step",
|
||||
[],
|
||||
None,
|
||||
"{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "history.restore";
|
||||
let id = active(s, c)?;
|
||||
let i = f64_req(p, "index", c)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(c, "no such step"));
|
||||
}
|
||||
go_to_history_step(s, c, id, i as usize)
|
||||
}
|
||||
),
|
||||
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
|
||||
let c = "history.previous";
|
||||
let id = active(s, c)?;
|
||||
let i = s
|
||||
.catalog
|
||||
.photo(id)
|
||||
.and_then(|ph| ph.history_index())
|
||||
.and_then(|i| i.checked_sub(1))
|
||||
.ok_or_else(|| bad(c, "at the first History step"))?;
|
||||
go_to_history_step(s, c, id, i)
|
||||
}),
|
||||
cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| {
|
||||
let c = "history.next";
|
||||
let id = active(s, c)?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let i = ph
|
||||
.history_index()
|
||||
.map(|i| i.saturating_add(1))
|
||||
.filter(|i| *i < ph.history.len())
|
||||
.ok_or_else(|| bad(c, "at the latest History step"))?;
|
||||
go_to_history_step(s, c, id, i)
|
||||
}),
|
||||
// ---- interactions (slider drags, brush strokes)
|
||||
cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| {
|
||||
@@ -1104,3 +1145,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later
|
||||
/// steps stay listed (one undo step).
|
||||
fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result<Value> {
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?;
|
||||
let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i);
|
||||
let label = format!("History: {}", st.label);
|
||||
let edited = Some((s.clock)());
|
||||
s.commit(
|
||||
&label,
|
||||
Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] },
|
||||
)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn has_history_before(s: &Session) -> std::result::Result<(), String> {
|
||||
let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index());
|
||||
if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) }
|
||||
}
|
||||
|
||||
fn has_history_after(s: &Session) -> std::result::Result<(), String> {
|
||||
let photo = s.active().and_then(|id| s.catalog.photo(id));
|
||||
if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("at the latest History step".into())
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use lightcraft_meta::{Region, RegionKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection};
|
||||
use crate::{Result, Session};
|
||||
use crate::{AiModel, EngineError, Result, Session};
|
||||
|
||||
const C: &str = "faces.detect";
|
||||
/// Regions made by this command say so in their description; only those are replaced on a new run.
|
||||
@@ -34,15 +34,39 @@ const EXISTING_IOU: f64 = 0.3;
|
||||
/// What to tell someone who has not downloaded the detector yet.
|
||||
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
|
||||
|
||||
/// Why [`detector`] has no detector to give.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum NoDetector {
|
||||
/// Not downloaded yet: a frontend offers the download ([`crate::ErrorKind::ModelMissing`]).
|
||||
NotInstalled,
|
||||
/// Nowhere to keep it, or there but unusable: says what to do.
|
||||
Unusable(String),
|
||||
}
|
||||
|
||||
impl NoDetector {
|
||||
/// Command `cmd`'s error for it. The message reads as before kinds existed (an invalid-parameters
|
||||
/// error), so control-channel and MCP clients see the same `error` text.
|
||||
pub(crate) fn into_error(self, cmd: &str) -> EngineError {
|
||||
match self {
|
||||
Self::NotInstalled => EngineError::ModelMissing { model: AiModel::FaceDetector, msg: bad(cmd, NOT_INSTALLED).to_string() },
|
||||
Self::Unusable(why) => bad(cmd, why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
|
||||
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
|
||||
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String> {
|
||||
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID);
|
||||
let home = s
|
||||
.face_models_dir
|
||||
.as_deref()
|
||||
.ok_or_else(|| NoDetector::Unusable("this build has nowhere to keep face models (the desktop app does)".into()))?
|
||||
.join(YUNET_ID);
|
||||
let path = home.join("model.onnx");
|
||||
// installing writes the manifest last, so a folder without one is not (yet) a model
|
||||
if !path.is_file() || !home.join("face-model.json").is_file() {
|
||||
return Err(NOT_INSTALLED.into());
|
||||
return Err(NoDetector::NotInstalled);
|
||||
}
|
||||
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some((p, d)) = loaded.as_ref()
|
||||
@@ -50,12 +74,14 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String
|
||||
{
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let len = std::fs::metadata(&path).map_err(|e| format!("could not read the detector: {e}"))?.len();
|
||||
let len = std::fs::metadata(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?.len();
|
||||
if len > MAX_MODEL_BYTES {
|
||||
return Err("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into());
|
||||
return Err(NoDetector::Unusable("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into()));
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?;
|
||||
let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?);
|
||||
let bytes = std::fs::read(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?;
|
||||
let d = Arc::new(
|
||||
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
|
||||
);
|
||||
*loaded = Some((path, d.clone()));
|
||||
Ok(d)
|
||||
}
|
||||
@@ -103,7 +129,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),
|
||||
max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),
|
||||
};
|
||||
let detector = detector(s).map_err(|e| bad(C, e))?;
|
||||
let detector = detector(s).map_err(|e| e.into_error(C))?;
|
||||
let started = Instant::now();
|
||||
let (mut results, mut ops) = (Vec::new(), Vec::new());
|
||||
for id in s.targets(p) {
|
||||
|
||||
@@ -219,6 +219,12 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
|
||||
Ok(json!({"active": vis[next].0}))
|
||||
}
|
||||
|
||||
/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input
|
||||
/// can't overflow).
|
||||
fn row_step(p: &Value) -> isize {
|
||||
p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let mut ops = Vec::new();
|
||||
@@ -641,6 +647,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),
|
||||
cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)),
|
||||
cmd!(
|
||||
"library.down",
|
||||
"Photo Below",
|
||||
[],
|
||||
Some("Down"),
|
||||
"{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}",
|
||||
always,
|
||||
|s, p| { step(s, row_step(p)) }
|
||||
),
|
||||
cmd!(
|
||||
"library.up",
|
||||
"Photo Above",
|
||||
[],
|
||||
Some("Up"),
|
||||
"{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}",
|
||||
always,
|
||||
|s, p| { step(s, -row_step(p)) }
|
||||
),
|
||||
// ---- rating / flags / labels
|
||||
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
|
||||
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
|
||||
|
||||
@@ -84,29 +84,29 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
|
||||
match shape {
|
||||
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
|
||||
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
s.segmenter.require_model()?;
|
||||
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
|
||||
}
|
||||
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
|
||||
*seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
|
||||
*seg = Some(segment(s, |s| s.segment_clicks(id, &clicks_of(hint, exclude)))?);
|
||||
}
|
||||
MaskShape::Object { .. } => {
|
||||
// the clicks come later: refuse an Object mask that could never be computed (no
|
||||
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
s.segmenter.require_model()?;
|
||||
if background {
|
||||
s.segment_prepare(id).map_err(ai_err)?;
|
||||
segment(s, |s| s.segment_prepare(id))?;
|
||||
}
|
||||
}
|
||||
MaskShape::Prompt { text, .. } if background => {
|
||||
s.segmenter.model_dir().map_err(ai_err)?;
|
||||
s.segmenter.require_model()?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(ai_err("describe what to select"));
|
||||
}
|
||||
return Ok(Later::Text(text.clone()));
|
||||
}
|
||||
MaskShape::Prompt { text, seg, .. } => {
|
||||
let found = s.segment_text(id, text).map_err(ai_err)?;
|
||||
let found = segment(s, |s| s.segment_text(id, text))?;
|
||||
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
|
||||
}
|
||||
_ => {}
|
||||
@@ -118,7 +118,7 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
|
||||
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
|
||||
if let Later::Clicks(hint, exclude) = later {
|
||||
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
|
||||
s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
|
||||
segment(s, |s| s.segment_clicks_later(id, mask, comp, hint, exclude))?;
|
||||
out["pending"] = json!(true);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -129,6 +129,13 @@ fn ai_err(msg: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
/// A segmentation call: the model is checked first, so a missing one is [`crate::EngineError::ModelMissing`]
|
||||
/// (the segmenter's own errors are text).
|
||||
fn segment<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> std::result::Result<T, String>) -> Result<T> {
|
||||
s.segmenter.require_model()?;
|
||||
f(s).map_err(ai_err)
|
||||
}
|
||||
|
||||
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
|
||||
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
|
||||
}
|
||||
@@ -167,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
if let Later::Text(text) = &later {
|
||||
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
|
||||
s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
|
||||
segment(s, |s| s.segment_text_later(id, None, "add", name, text))?;
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
@@ -205,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
|
||||
if let Later::Text(text) = &later {
|
||||
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
|
||||
s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
|
||||
segment(s, |s| s.segment_text_later(id, Some(mid), op, None, text))?;
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let mut comp = 0;
|
||||
@@ -365,10 +372,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
if s.segmenter.background && !hint.is_empty() {
|
||||
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
|
||||
s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
|
||||
segment(s, |s| s.segment_clicks_later(id, mid, k, hint, ex))?;
|
||||
return Ok(out);
|
||||
}
|
||||
let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
|
||||
let seg = if hint.is_empty() { None } else { Some(segment(s, |s| s.segment_clicks(id, &clicks_of(&hint, &ex)))?) };
|
||||
let out = json!({"include": hint.len(), "exclude": ex.len()});
|
||||
masks_edit(s, c, "Object Mask", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
@@ -401,7 +408,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
|
||||
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
|
||||
};
|
||||
let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
|
||||
let started = segment(s, |s| s.segment_detail(id, mid, k))?;
|
||||
Ok(json!({"started": started}))
|
||||
}
|
||||
),
|
||||
@@ -415,7 +422,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| {
|
||||
let c = "segment.prepare";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
s.segment_prepare(id).map_err(ai_err)?;
|
||||
segment(s, |s| s.segment_prepare(id))?;
|
||||
Ok(json!({"busy": s.segmenter.busy()}))
|
||||
}
|
||||
),
|
||||
@@ -639,7 +646,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let i = find(&mut masks, mid, "mask.move")?;
|
||||
let to = match (p.get("to").and_then(Value::as_i64), p.get("delta").and_then(Value::as_i64)) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(d)) => i as i64 + d,
|
||||
// (an agent's delta can be anything: saturate rather than overflow)
|
||||
(None, Some(d)) => (i as i64).saturating_add(d),
|
||||
(None, None) => return Err(bad("mask.move", "give `to` (index) or `delta`")),
|
||||
}
|
||||
.clamp(0, masks.len() as i64 - 1) as usize;
|
||||
@@ -700,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let c = "spot.findBlemishes";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
|
||||
let detector = super::face_detect::detector(s).map_err(|e| e.into_error(c))?;
|
||||
// the uncropped photo, so positions are the spots' own coordinates
|
||||
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
|
||||
|
||||
@@ -31,14 +31,24 @@ pub fn photo_summary(p: &Photo) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value {
|
||||
/// How deep [`album_json`] lists folders inside folders: a catalog that loops (an album inside
|
||||
/// itself, which [`lightcraft_catalog::Catalog::repair`] puts right on load) lists no further
|
||||
/// instead of overflowing the stack.
|
||||
const MAX_ALBUM_DEPTH: usize = 64;
|
||||
|
||||
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
|
||||
let children: Vec<Value> = if depth < MAX_ALBUM_DEPTH {
|
||||
all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut v = json!({
|
||||
"id": a.id.0,
|
||||
"name": a.name,
|
||||
"folder": a.folder,
|
||||
"count": cat.album_count(a.id),
|
||||
"cover": a.cover.map(|c| c.0),
|
||||
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::<Vec<_>>(),
|
||||
"children": children,
|
||||
});
|
||||
if let Some(rules) = &a.smart {
|
||||
v["smart"] = json!(true);
|
||||
@@ -139,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
|
||||
let all: Vec<Album> = s.catalog.albums().cloned().collect();
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect()))
|
||||
}),
|
||||
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
|
||||
let id = photo_arg(s, p, "photo.inspect")?;
|
||||
@@ -195,20 +205,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| {
|
||||
Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect()))
|
||||
}),
|
||||
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| {
|
||||
Ok(Value::Array(
|
||||
crate::presets::PROFILES
|
||||
.iter()
|
||||
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)}))
|
||||
.collect(),
|
||||
))
|
||||
cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile (built-in, then imported LUTs marked `imported`) with its group and favourite flag", always, |s, _| {
|
||||
let favorite = |id: &str| s.profile_favorites.iter().any(|f| f == id);
|
||||
let builtin = crate::presets::PROFILES.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(p.id)}));
|
||||
// imported LUT profiles too (#618), marked as in `profiles.menu`
|
||||
let imported =
|
||||
s.lut_profiles.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(&p.id), "imported": true}));
|
||||
Ok(Value::Array(builtin.chain(imported).collect()))
|
||||
}),
|
||||
cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())),
|
||||
cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| {
|
||||
cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| {
|
||||
let id = photo_arg(s, p, "history.list")?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?;
|
||||
Ok(json!({
|
||||
"history": ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"cursor": ph.history_index(),
|
||||
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -12,8 +12,9 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let owners = crate::sidecar::StemOwners::of(&s.catalog);
|
||||
for id in s.targets(p) {
|
||||
let ids = s.targets(p);
|
||||
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
|
||||
for id in ids {
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
Ok(r) => {
|
||||
let path = r.path.display().to_string();
|
||||
@@ -49,6 +50,10 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Read Metadata from File", Op::Batch { ops })?;
|
||||
// Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had
|
||||
// only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's
|
||||
// next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them.
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
let mut result = json!({"read": read, "failed": failed});
|
||||
if !warnings.is_empty() {
|
||||
@@ -90,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Read Metadata from File",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
has_selection,
|
||||
read
|
||||
),
|
||||
|
||||
+129
-16
@@ -1,16 +1,31 @@
|
||||
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera
|
||||
//! profiles, face and denoise models).
|
||||
//!
|
||||
//! [`config_dir`] picks it, in order:
|
||||
//! 1. `$LIGHTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (issue #1200, as in PhotoCraft): a marker file ([`PORTABLE_MARKERS`]) beside the
|
||||
//! executable puts everything in `<exe dir>/LightCraftData`, so a copy on a USB stick leaves nothing in
|
||||
//! `%APPDATA%`. If that folder can't be written (read-only medium, Program Files), a warning is logged
|
||||
//! and the platform folder is used.
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/LightCraft`, Windows
|
||||
//! `%APPDATA%\LightCraft`, Linux `$XDG_CONFIG_HOME/lightcraft` or `~/.config/lightcraft`.
|
||||
//!
|
||||
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
|
||||
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
|
||||
//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`],
|
||||
//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "LightCraft.portable"];
|
||||
/// The data folder created beside the executable in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "LightCraftData";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
Mac,
|
||||
@@ -18,9 +33,9 @@ enum Platform {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides portable mode and the
|
||||
/// platform default. The override is the complete directory; no application-name suffix is appended. An
|
||||
/// explicitly empty value disables the settings directory instead of falling back to a personal profile.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
@@ -29,13 +44,17 @@ pub fn config_dir() -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
config_dir_with(platform, |name| std::env::var_os(name), portable_dir)
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
/// `portable` is asked only without an override, so an isolated run never probes beside the executable.
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
if let Some(dir) = portable() {
|
||||
return Some(dir);
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
|
||||
@@ -45,6 +64,50 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
|
||||
}
|
||||
}
|
||||
|
||||
/// This process's portable data folder, if a marker sits beside its executable and the folder can be
|
||||
/// written. Resolved once: the check creates the folder and writes a probe file.
|
||||
fn portable_dir() -> Option<PathBuf> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
match portable_dir_in(exe.parent()?) {
|
||||
Ok(dir) => dir,
|
||||
Err(why) => {
|
||||
log::warn!("{why}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// `<exe_dir>/LightCraftData` when `exe_dir` holds a portable marker; `Err` says why a marked folder
|
||||
/// can't be used (the caller falls back to the platform folder).
|
||||
fn portable_dir_in(exe_dir: &Path) -> Result<Option<PathBuf>, String> {
|
||||
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match ensure_writable(&data) {
|
||||
Ok(()) => Ok(Some(data)),
|
||||
Err(e) => Err(format!(
|
||||
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the usual folder instead",
|
||||
marker.display(),
|
||||
data.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `<config>/models`.
|
||||
pub fn default_face_models_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models")))
|
||||
@@ -101,16 +164,62 @@ mod config_tests {
|
||||
use super::*;
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), || None)
|
||||
}
|
||||
|
||||
/// An override is used as is: portable mode is not even looked for.
|
||||
fn no_portable_probe() -> Option<PathBuf> {
|
||||
panic!("an override must not probe for portable mode")
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-config-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A marker beside the executable moves the settings folder next to it, ahead of the platform folder (issue #1200).
|
||||
#[test]
|
||||
fn portable_marker_puts_settings_beside_the_executable() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let exe = temp(&format!("portable-{marker}"));
|
||||
assert_eq!(portable_dir_in(&exe), Ok(None), "no marker, no portable folder");
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let data = exe.join(PORTABLE_DATA_DIR);
|
||||
assert_eq!(portable_dir_in(&exe), Ok(Some(data.clone())));
|
||||
assert!(data.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0, "the write probe is removed");
|
||||
let found = portable_dir_in(&exe).unwrap();
|
||||
let actual = config_dir_with(Platform::Windows, |name| (name == "APPDATA").then(|| OsString::from("/roaming")), || found);
|
||||
assert_eq!(actual, Some(data));
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
}
|
||||
|
||||
/// A marked folder that can't hold the data folder (here: a file is in its place) is reported, not used.
|
||||
#[test]
|
||||
fn portable_folder_that_cannot_be_written_is_refused() {
|
||||
let exe = temp("portable-blocked");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"").unwrap();
|
||||
let why = portable_dir_in(&exe).unwrap_err();
|
||||
assert!(why.contains("portable.txt") && why.contains(PORTABLE_DATA_DIR), "{why}");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
let actual = config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
},
|
||||
no_portable_probe,
|
||||
);
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
@@ -119,10 +228,14 @@ mod config_tests {
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
},
|
||||
no_portable_probe
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -145,7 +258,7 @@ mod config_tests {
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), no_portable_probe), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
let mut pts = Vec::new();
|
||||
for it in items {
|
||||
let (x, y) = it.split_once(',')?;
|
||||
let (x, y) = (x.trim().parse::<f64>().ok()?, y.trim().parse::<f64>().ok()?);
|
||||
// (`parse` reads "NaN" and "inf" too: a curve with such a point is not a curve)
|
||||
let (x, y) = (x.trim().parse::<f64>().ok().filter(|v| v.is_finite())?, y.trim().parse::<f64>().ok().filter(|v| v.is_finite())?);
|
||||
pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)}));
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
@@ -617,6 +618,19 @@ mod tests {
|
||||
assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
|
||||
}
|
||||
|
||||
/// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve:
|
||||
/// it is left out, rather than stored with a point the curve editor can't place.
|
||||
#[test]
|
||||
fn curves_with_non_finite_points_are_left_out() {
|
||||
for bad in ["NaN, 0", "64, inf", "-inf, 3"] {
|
||||
let x = SIDECAR.replace("<rdf:li>64, 52</rdf:li>", &format!("<rdf:li>{bad}</rdf:li>"));
|
||||
let partial = to_partial(&props(&x), Target::RawAbsolute);
|
||||
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
|
||||
assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master);
|
||||
assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_present_fields_are_in_the_partial() {
|
||||
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! What kind of error a command returned, for code that decides on it (#1046).
|
||||
//!
|
||||
//! A frontend that reacts to an error (offers a model download, says "cancelled" in its own words
|
||||
//! instead of showing a failure) matches [`EngineError::kind`], never the message: messages are
|
||||
//! for people, get reworded, and will be translated.
|
||||
|
||||
use crate::EngineError;
|
||||
|
||||
/// A model a feature needs and LightCraft downloads only when the person agrees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum AiModel {
|
||||
/// YuNet, which finds faces (`faces.detect`; installed in Settings ▸ Faces).
|
||||
FaceDetector,
|
||||
/// SAM 3, behind Object and Describe masks (`segment.model.download`).
|
||||
Sam3,
|
||||
}
|
||||
|
||||
/// The kind of an [`EngineError`] (or of an error a frontend raised itself: [`ErrorKind::Other`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ErrorKind {
|
||||
UnknownCommand,
|
||||
Disabled,
|
||||
BadParams,
|
||||
Catalog,
|
||||
NotSaved,
|
||||
LibraryInUse,
|
||||
LightroomCatalog,
|
||||
/// The work was cancelled: not a failure.
|
||||
Cancelled,
|
||||
/// The feature needs this model, which is not installed (or still downloading).
|
||||
ModelMissing(AiModel),
|
||||
Other,
|
||||
}
|
||||
|
||||
impl EngineError {
|
||||
/// What kind of error this is: decide on this, not on the message.
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
Self::UnknownCommand(_) => ErrorKind::UnknownCommand,
|
||||
Self::Disabled(..) => ErrorKind::Disabled,
|
||||
Self::BadParams { .. } => ErrorKind::BadParams,
|
||||
Self::Catalog(_) => ErrorKind::Catalog,
|
||||
Self::NotSaved(_) => ErrorKind::NotSaved,
|
||||
Self::LibraryInUse(_) => ErrorKind::LibraryInUse,
|
||||
Self::LightroomCatalog { .. } => ErrorKind::LightroomCatalog,
|
||||
Self::Cancelled(_) => ErrorKind::Cancelled,
|
||||
Self::ModelMissing { model, .. } => ErrorKind::ModelMissing(*model),
|
||||
Self::Other(_) => ErrorKind::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_kind_does_not_depend_on_the_message() {
|
||||
let cancelled = EngineError::Cancelled("Lightroom-Import abgebrochen".into());
|
||||
assert_eq!(cancelled.kind(), ErrorKind::Cancelled);
|
||||
assert_eq!(cancelled.to_string(), "Lightroom-Import abgebrochen", "the message is shown as is");
|
||||
let missing = EngineError::ModelMissing { model: AiModel::Sam3, msg: "any wording".into() };
|
||||
assert_eq!(missing.kind(), ErrorKind::ModelMissing(AiModel::Sam3));
|
||||
assert_eq!(missing.to_string(), "any wording");
|
||||
// an `Other` whose text reads like a cancellation or a missing model is still `Other`
|
||||
assert_eq!(EngineError::Other("The SAM 3 model is not installed; cancelled".into()).kind(), ErrorKind::Other);
|
||||
}
|
||||
}
|
||||
+208
-6
@@ -28,6 +28,8 @@ pub enum ExportFormat {
|
||||
Original,
|
||||
/// A DNG of the raw data (raw photos only), the edits embedded as XMP.
|
||||
Dng,
|
||||
/// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in.
|
||||
RenderedDng,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
@@ -42,6 +44,7 @@ impl ExportFormat {
|
||||
"jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl,
|
||||
"original" => Self::Original,
|
||||
"dng" => Self::Dng,
|
||||
"rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -56,7 +59,7 @@ impl ExportFormat {
|
||||
Self::Jxl => "jxl",
|
||||
// the original keeps its own extension (see `ExportOptions::file_name_for`)
|
||||
Self::Original => "",
|
||||
Self::Dng => "dng",
|
||||
Self::Dng | Self::RenderedDng => "dng",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +108,10 @@ impl Default for Resize {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also
|
||||
/// Lightroom's ceiling. A full-size or smaller export is never held to it.
|
||||
pub const MAX_ENLARGED_PIXELS: f64 = 512e6;
|
||||
|
||||
impl Resize {
|
||||
pub fn long_edge(px: u32) -> Self {
|
||||
Self { value: px as f32, ..Default::default() }
|
||||
@@ -128,8 +135,12 @@ impl Resize {
|
||||
ResizeMode::Percent => v / 100.0,
|
||||
};
|
||||
let k = if self.dont_enlarge { k.min(1.0) } else { k };
|
||||
// at least one pixel, at most the encoders' 65535 limit
|
||||
k.clamp(1.0 / short, 65_535.0 / long)
|
||||
// an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000
|
||||
// with Don't Enlarge off asked for gigapixels, more memory than a computer has
|
||||
let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0));
|
||||
// at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more
|
||||
// than 65535 times as long as it is short (where `clamp` would panic: min above max)
|
||||
k.max(1.0 / short).min(65_535.0 / long)
|
||||
}
|
||||
|
||||
/// Output size for a `w × h` (cropped, full-resolution) photo.
|
||||
@@ -623,6 +634,32 @@ pub enum MetadataPolicy {
|
||||
None,
|
||||
}
|
||||
|
||||
/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or
|
||||
/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HdrCurve {
|
||||
#[default]
|
||||
Pq,
|
||||
Hlg,
|
||||
}
|
||||
|
||||
impl HdrCurve {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"pq" => Some(Self::Pq),
|
||||
"hlg" => Some(Self::Hlg),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn codec(self) -> lightcraft_codecs::HdrTransfer {
|
||||
match self {
|
||||
Self::Pq => lightcraft_codecs::HdrTransfer::Pq,
|
||||
Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportOptions {
|
||||
@@ -681,6 +718,8 @@ pub struct ExportOptions {
|
||||
pub add_to_photos: bool,
|
||||
/// The Photos album they go into, made when missing (empty = none).
|
||||
pub photos_album: String,
|
||||
/// The curve of an HDR AVIF: PQ (default) or HLG.
|
||||
pub hdr_transfer: HdrCurve,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -710,6 +749,7 @@ impl Default for ExportOptions {
|
||||
jxl_progressive: false,
|
||||
add_to_photos: false,
|
||||
photos_album: String::new(),
|
||||
hdr_transfer: HdrCurve::Pq,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,6 +791,7 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"jxlProgressive",
|
||||
"addToPhotos",
|
||||
"photosAlbum",
|
||||
"hdrTransfer",
|
||||
];
|
||||
|
||||
/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the
|
||||
@@ -923,6 +964,7 @@ impl ExportOptions {
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
@@ -1061,6 +1103,7 @@ impl ExportOptions {
|
||||
jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive),
|
||||
add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos),
|
||||
photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()),
|
||||
hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1176,8 @@ impl ExportOptions {
|
||||
(ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Tiff, _) => OutputDepth::U16,
|
||||
(ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::RenderedDng, _) => OutputDepth::F32Linear,
|
||||
(ExportFormat::Psd, Some(8)) => OutputDepth::U8,
|
||||
(ExportFormat::Psd, _) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
@@ -1151,6 +1196,7 @@ impl ExportOptions {
|
||||
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
|
||||
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
|
||||
ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")],
|
||||
ExportFormat::RenderedDng => &[(16, "16-bit float")],
|
||||
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
|
||||
ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")],
|
||||
}
|
||||
@@ -1341,6 +1387,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta),
|
||||
f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
|
||||
ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1431,6 +1478,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met
|
||||
|
||||
/// Encode a high-bit-depth image (see [`encode_rendered`]).
|
||||
pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let meta_src = meta;
|
||||
let img = finish_deep(img, o);
|
||||
let profile = match img.samples {
|
||||
DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),
|
||||
@@ -1449,8 +1497,12 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
||||
(ExportFormat::Png, _) => encode::encode_png(&e, &meta),
|
||||
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
(ExportFormat::Psd, _) => encode::encode_psd(&e, &meta),
|
||||
(ExportFormat::RenderedDng, DeepSamples::F32(v)) => {
|
||||
let m = meta_src.cloned().unwrap_or_default();
|
||||
return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string());
|
||||
}
|
||||
(ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => {
|
||||
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
|
||||
lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec())
|
||||
}
|
||||
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
(ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => {
|
||||
@@ -1536,9 +1588,11 @@ pub struct Exported {
|
||||
/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`).
|
||||
pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) {
|
||||
let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||||
// whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie
|
||||
let (w, h) = (w.round().max(1.0), h.round().max(1.0));
|
||||
match &o.resize {
|
||||
Some(r) => r.apply(w, h),
|
||||
None => ((w.round() as usize).max(1), (h.round() as usize).max(1)),
|
||||
None => (w as usize, h as usize),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1624,10 +1678,16 @@ fn prepare_guarded(
|
||||
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
|
||||
let (work, weight) = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||||
let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize;
|
||||
let label = p.file_name.clone();
|
||||
let meta = export_metadata(p, &session.catalog, o);
|
||||
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
|
||||
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
|
||||
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
|
||||
if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {
|
||||
return Err(format!("{label}: {why}"));
|
||||
}
|
||||
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
|
||||
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
|
||||
} else {
|
||||
@@ -1651,6 +1711,26 @@ fn prepare_guarded(
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
|
||||
}
|
||||
|
||||
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
|
||||
/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export
|
||||
/// enlarged past the photo's size that needs more memory than the computer has would fail to
|
||||
/// allocate, which ends the process rather than the export. Full-size and smaller exports, and
|
||||
/// computers whose memory isn't known, are never refused.
|
||||
fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option<usize>) -> Option<String> {
|
||||
let ram = ram?;
|
||||
if out.0.saturating_mul(out.1) <= native_px || weight <= ram {
|
||||
return None;
|
||||
}
|
||||
let gb = |b: usize| b as f64 / (1u64 << 30) as f64;
|
||||
Some(format!(
|
||||
"exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size",
|
||||
out.0,
|
||||
out.1,
|
||||
gb(weight).ceil(),
|
||||
gb(ram)
|
||||
))
|
||||
}
|
||||
|
||||
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
|
||||
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
|
||||
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
|
||||
@@ -2692,10 +2772,112 @@ mod tests {
|
||||
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
|
||||
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
|
||||
// never larger than the encoders' limit, never empty
|
||||
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((65535, 43690)));
|
||||
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most");
|
||||
assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge");
|
||||
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
|
||||
}
|
||||
|
||||
/// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the
|
||||
/// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the
|
||||
/// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos
|
||||
/// larger than that are untouched.
|
||||
#[test]
|
||||
fn enlargements_stop_at_512_megapixels() {
|
||||
let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap();
|
||||
for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] {
|
||||
let (w, h) = size(p.clone(), 6000.0, 4000.0);
|
||||
assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}");
|
||||
assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape");
|
||||
}
|
||||
// below the ceiling an enlargement is as asked
|
||||
assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000));
|
||||
// a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged
|
||||
assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
|
||||
assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000));
|
||||
assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
|
||||
}
|
||||
|
||||
/// An enlarged export that needs more memory than the computer has is refused with a message,
|
||||
/// before it starts; everything else goes ahead.
|
||||
#[test]
|
||||
fn enlarged_exports_must_fit_the_computers_memory() {
|
||||
let gb = 1usize << 30;
|
||||
let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap();
|
||||
assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}");
|
||||
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits");
|
||||
assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused");
|
||||
assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export");
|
||||
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_size_survives_the_render() {
|
||||
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
|
||||
let cropped = |r: Resize, w: u32, h: u32, x1: f64| {
|
||||
let mut p = named("IMG.ARW");
|
||||
(p.width, p.height) = (w, h);
|
||||
std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0);
|
||||
let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() });
|
||||
lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh))
|
||||
};
|
||||
let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0);
|
||||
let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true };
|
||||
// a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5
|
||||
let hair = 1.0 - 1e-9;
|
||||
assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365));
|
||||
assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333));
|
||||
assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000));
|
||||
// 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says
|
||||
for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] {
|
||||
for (w, h) in [(w, h), (h, w)] {
|
||||
for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] {
|
||||
for r in [
|
||||
Resize::long_edge(value as u32),
|
||||
mode(ResizeMode::ShortEdge, value, 0),
|
||||
mode(ResizeMode::Width, value, 0),
|
||||
mode(ResizeMode::Height, value, 0),
|
||||
mode(ResizeMode::Dimensions, value, 1365),
|
||||
mode(ResizeMode::Megapixels, value / 1000.0, 0),
|
||||
mode(ResizeMode::Percent, value / 100.0, 0),
|
||||
] {
|
||||
let want = r.apply(w.into(), h.into());
|
||||
assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale`
|
||||
/// clamp with its lower bound above its upper one, which panics, in the export and in the
|
||||
/// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a
|
||||
/// pixel.
|
||||
#[test]
|
||||
fn resize_survives_photos_too_long_for_the_encoders() {
|
||||
let modes = [
|
||||
json!({"longEdge": 2000}),
|
||||
json!({"shortEdge": 1}),
|
||||
json!({"width": 500, "height": 500}),
|
||||
json!({"megapixels": 1}),
|
||||
json!({"percent": 50}),
|
||||
json!({"percent": 1000, "dontEnlarge": false}),
|
||||
];
|
||||
for p in modes {
|
||||
for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] {
|
||||
let r = ExportOptions::from_json(&p).resize.unwrap();
|
||||
let (ow, oh) = r.apply(w, h);
|
||||
assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}");
|
||||
assert!(r.scale(w, h).is_finite());
|
||||
}
|
||||
}
|
||||
// (a short edge under a pixel would be scaled up to one, which the limit then caps)
|
||||
assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1));
|
||||
assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535));
|
||||
}
|
||||
|
||||
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
|
||||
fn p3_green(space: OutputSpace) -> Rgba8 {
|
||||
use lightcraft_color::{DISPLAY_P3, REC2020};
|
||||
@@ -3109,6 +3291,26 @@ mod tests {
|
||||
}
|
||||
|
||||
// Issue #496: photos rendered side by side give the same files, names, order and progress as
|
||||
// Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture
|
||||
#[test]
|
||||
fn rendered_dng_matches_the_rendered_tiff() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.visible()[0];
|
||||
let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap();
|
||||
assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name);
|
||||
let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap();
|
||||
let raw = lightcraft_raw::decode(&dng.bytes).unwrap();
|
||||
assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3));
|
||||
assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one");
|
||||
let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
|
||||
let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image;
|
||||
assert_eq!((reference.width, reference.height), (back.width, back.height));
|
||||
// mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB
|
||||
let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::<f32>() / back.data.len() as f32;
|
||||
let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::<f32>() / reference.data.len() as f32;
|
||||
assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}");
|
||||
}
|
||||
|
||||
// one at a time, and a cancel stops at the same photo
|
||||
#[test]
|
||||
fn side_by_side_batches_match_one_at_a_time() {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Face tiles cut from a raw's embedded preview (issue #1440).
|
||||
//!
|
||||
//! A People card's face picture ([`crate::Session::face_job`]) is a small square around the face.
|
||||
//! Developing the raw for it reads and demosaics most of a 24–50 MB file for a ~170 px picture; the
|
||||
//! camera's own JPEG inside the file (read through the planned preview path,
|
||||
//! [`crate::files::preview_window`], which reads the header and the preview and not the sensor
|
||||
//! data) usually holds that square at the tile's size already. A [`PreviewCut`] says which preview
|
||||
//! to read and how large it must be; [`crate::media::RenderJob::run`] tries it before the job's own
|
||||
//! source and falls back to that source when the preview can't serve.
|
||||
//!
|
||||
//! Only a raw that still looks the way it was imported gets one: that is the photo whose grid
|
||||
//! thumbnail is the same embedded JPEG ([`crate::Session::embedded_thumb_is_final`]). An edited raw
|
||||
//! needs its edits rendered, which the camera's display-referred JPEG can't take the way the raw
|
||||
//! does, so it keeps the full develop render.
|
||||
|
||||
use lightcraft_geom::Rect;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use lightcraft_raster::resample::{Filter, resize};
|
||||
|
||||
use crate::media::PreviewLoader;
|
||||
|
||||
/// How much smaller than asked an embedded preview may be and still serve a face tile: at least
|
||||
/// three quarters of the pixels the tile asks for across the face. The square is then enlarged by
|
||||
/// at most 4⁄3 on its way to the tile, which at a 56–180 pt tile is not visible (the camera's JPEG
|
||||
/// is sharpened and denoised, so its pixels hold more detail than a binned raw's), and it is the
|
||||
/// same bound the raw path already accepts for an X-Trans sensor binned 3× for a preview
|
||||
/// ([`crate::files::bin_factor`]). Below it the tile would look soft: the job's own source renders it.
|
||||
pub const SOFT_LIMIT: f64 = 0.75;
|
||||
|
||||
/// Largest relative difference between the preview's and the photo's aspect ratios: a preview of
|
||||
/// another shape (letterboxed, 16:9 from a 3:2 sensor, a 4:3 sensor's 3:2 JPEG) does not show the
|
||||
/// same frame, and the face box would land beside the face. Real previews of the same frame differ
|
||||
/// by rounding only (Sony A7S III: 1616 × 1080 against 4240 × 2832, 0.06 %).
|
||||
const ASPECT_TOLERANCE: f64 = 0.02;
|
||||
|
||||
/// A face tile to cut from the photo's embedded preview.
|
||||
#[derive(Clone)]
|
||||
pub struct PreviewCut {
|
||||
/// The raw file.
|
||||
pub path: String,
|
||||
/// Reads its largest embedded preview, display-ready and upright, no larger than the edge given.
|
||||
pub loader: PreviewLoader,
|
||||
/// The preview's long edge that gives the tile its own size across the face.
|
||||
pub want: usize,
|
||||
/// The smallest long edge accepted ([`SOFT_LIMIT`] of `want`).
|
||||
pub min: usize,
|
||||
/// The square around the face, normalized on the upright photo.
|
||||
pub rect: Rect,
|
||||
/// The photo's width / height, upright.
|
||||
pub aspect: f64,
|
||||
/// The tile's edge, pixels.
|
||||
pub edge: usize,
|
||||
}
|
||||
|
||||
impl PreviewCut {
|
||||
/// The tile, or `None` when the preview can't give it: none could be read (no preview, an
|
||||
/// offline or damaged file), it is smaller than [`Self::min`], or it shows another frame.
|
||||
pub fn cut(&self) -> Option<Rgba8> {
|
||||
let preview = (self.loader)(&self.path, self.want)?;
|
||||
self.cut_from(&preview)
|
||||
}
|
||||
|
||||
/// [`Self::cut`] from a preview already read.
|
||||
pub fn cut_from(&self, img: &Rgba8) -> Option<Rgba8> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w == 0 || h == 0 || w.max(h) < self.min || img.data.len() != w.checked_mul(h)? {
|
||||
return None;
|
||||
}
|
||||
let aspect = w as f64 / h as f64;
|
||||
if !(self.aspect.is_finite() && self.aspect > 0.0) || ((aspect - self.aspect) / self.aspect).abs() > ASPECT_TOLERANCE {
|
||||
return None;
|
||||
}
|
||||
let r = self.rect;
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// pixel bounds inside the preview (the clamps keep every value in 0..=w / 0..=h)
|
||||
let px = |v: f64, n: usize| (v.clamp(0.0, 1.0) * n as f64).round() as usize;
|
||||
let (x0, x1, y0, y1) = (px(r.x0, w), px(r.x1, w), px(r.y0, h), px(r.y1, h));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let edge = self.edge.clamp(1, crate::media::SourceLevel::Thumb.max_edge());
|
||||
let square = img.crop(x0, y0, x1 - x0, y1 - y0).to_linear();
|
||||
Some(resize(&square, edge, edge, Filter::Mitchell).to_srgb8())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn cut(rect: Rect, aspect: f64, min: usize) -> PreviewCut {
|
||||
PreviewCut { path: String::new(), loader: Arc::new(|_, _| None), want: 300, min, rect, aspect, edge: 32 }
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgba8 {
|
||||
let mut img = Rgba8::new(w, h);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 90, 255];
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// The square comes out the tile's size, from the place the box names.
|
||||
#[test]
|
||||
fn cuts_the_square_at_the_tiles_size() {
|
||||
let img = picture(300, 200);
|
||||
let out = cut(Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.65 }, 1.5, 200).cut_from(&img).unwrap();
|
||||
assert_eq!((out.width, out.height), (32, 32));
|
||||
// red follows x: the box starts at half the width
|
||||
let red = out.data[16 * 32 + 16][0];
|
||||
assert!((125..=160).contains(&red), "{red}");
|
||||
}
|
||||
|
||||
/// A preview too small, of another shape, empty, or a box that is no box: no tile, no panic.
|
||||
#[test]
|
||||
fn declines_what_it_cannot_cut() {
|
||||
let img = picture(300, 200);
|
||||
let ok = Rect { x0: 0.4, y0: 0.4, x1: 0.5, y1: 0.55 };
|
||||
assert!(cut(ok, 1.5, 301).cut_from(&img).is_none(), "below the soft limit");
|
||||
assert!(cut(ok, 16.0 / 9.0, 200).cut_from(&img).is_none(), "another frame");
|
||||
assert!(cut(ok, f64::NAN, 200).cut_from(&img).is_none());
|
||||
assert!(cut(ok, 0.0, 200).cut_from(&img).is_none());
|
||||
assert!(cut(ok, 1.5, 0).cut_from(&Rgba8::new(0, 0)).is_none(), "empty preview");
|
||||
let short = Rgba8 { width: 300, height: 200, data: vec![[0; 4]; 10] };
|
||||
assert!(cut(ok, 1.5, 0).cut_from(&short).is_none(), "a buffer shorter than its size");
|
||||
for bad in [
|
||||
Rect { x0: f64::NAN, y0: 0.4, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: 0.6, y0: 0.6, x1: 0.4, y1: 0.4 },
|
||||
Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 },
|
||||
] {
|
||||
assert!(cut(bad, 1.5, 0).cut_from(&img).is_none(), "{bad:?}");
|
||||
}
|
||||
// a box past the edges is clamped to the picture
|
||||
let edge = cut(Rect { x0: -1.0, y0: -1.0, x1: 0.1, y1: 0.15 }, 1.5, 0).cut_from(&img).unwrap();
|
||||
assert_eq!((edge.width, edge.height), (32, 32));
|
||||
// and a loader with nothing to give leaves the job to its source
|
||||
assert!(cut(ok, 1.5, 0).cut().is_none());
|
||||
}
|
||||
}
|
||||
+528
-47
@@ -176,6 +176,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
kind: MediaKind::Raw,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
file_modified: None,
|
||||
meta,
|
||||
as_shot_wb,
|
||||
content_hash,
|
||||
@@ -207,6 +208,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
kind: MediaKind::Image,
|
||||
file_size: bytes.len() as u64,
|
||||
captured,
|
||||
file_modified: None,
|
||||
meta,
|
||||
as_shot_wb: None,
|
||||
content_hash,
|
||||
@@ -448,7 +450,8 @@ fn load_bytes_now(
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
colour.apply(&mut img);
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
// a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP)
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img };
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.into_oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
@@ -508,15 +511,16 @@ fn load_bytes_now(
|
||||
///
|
||||
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
|
||||
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
|
||||
/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look
|
||||
/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||||
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
|
||||
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
|
||||
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||||
struct WbScaleOf {
|
||||
own_matrix: bool,
|
||||
relative: bool,
|
||||
/// Kelvin through the camera's measured matrices: edited on the relative scale before those were used.
|
||||
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
|
||||
legacy_relative: bool,
|
||||
/// The as-shot white through the measured matrices, when that is where the scale comes from.
|
||||
spectral_white: Option<lightcraft_color::Xy>,
|
||||
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
|
||||
scale_white: Option<lightcraft_color::Xy>,
|
||||
}
|
||||
|
||||
impl WbScaleOf {
|
||||
@@ -528,18 +532,26 @@ impl WbScaleOf {
|
||||
) -> Self {
|
||||
let own_matrix = lightcraft_raw::color::has_matrix(color);
|
||||
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
|
||||
let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten();
|
||||
let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers));
|
||||
WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white }
|
||||
let model = meta.model.as_deref().filter(|_| fitted);
|
||||
let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model));
|
||||
let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model));
|
||||
let scale_white = match camera {
|
||||
Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers),
|
||||
None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers),
|
||||
None => None,
|
||||
};
|
||||
let covered = camera.is_some() || presets;
|
||||
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
|
||||
}
|
||||
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
|
||||
/// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog
|
||||
/// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65.
|
||||
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
|
||||
/// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to
|
||||
/// `own`, D65.
|
||||
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
|
||||
if self.relative {
|
||||
return (6500.0, 0.0);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own));
|
||||
let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own));
|
||||
(t.round(), tint.round())
|
||||
}
|
||||
}
|
||||
@@ -611,11 +623,16 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
|
||||
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
|
||||
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is
|
||||
/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first
|
||||
/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image
|
||||
/// bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8], covering: Option<usize>) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
let jpeg = match covering {
|
||||
Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)),
|
||||
None => lightcraft_raw::embedded_preview(bytes),
|
||||
};
|
||||
if let Some(jpeg) = jpeg {
|
||||
return Some(std::borrow::Cow::Owned(jpeg));
|
||||
}
|
||||
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
|
||||
@@ -623,9 +640,14 @@ fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
.then_some(std::borrow::Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see
|
||||
/// [`stand_in_image`]).
|
||||
pub(crate) fn decode_raw_preview(
|
||||
bytes: &[u8],
|
||||
opts: lightcraft_codecs::DecodeOptions,
|
||||
covering: Option<usize>,
|
||||
) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes, covering)?;
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
@@ -642,10 +664,17 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
if preview_orientation(bytes, d.orientation).swaps_axes() {
|
||||
let jpeg = stand_in_image(bytes, None)?;
|
||||
// headers only, as for other images (issue #367): decoding a full-size preview just for its
|
||||
// size took about a second per file in an import; a decode remains the fallback
|
||||
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
|
||||
Ok(header) => (header.width, header.height, header.orientation),
|
||||
Err(_) => {
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||||
(d.source_width, d.source_height, d.orientation)
|
||||
}
|
||||
};
|
||||
if preview_orientation(bytes, orientation).swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
Some((w, h))
|
||||
@@ -653,16 +682,18 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
// always the largest JPEG: a source's geometry must not depend on its size level (previews match exports)
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge), None)?;
|
||||
let img = d.to_working();
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest
|
||||
/// embedded JPEG that covers `max_edge` is decoded, not the largest.
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -681,15 +712,20 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
|
||||
/// Filesystem-backed embedded-preview hook (native).
|
||||
///
|
||||
/// The quick path reads the file's header and its embedded preview and nothing else (see
|
||||
/// [`preview_window`]); a format whose preview the header cannot point at falls back to the
|
||||
/// whole-file path, which is what every format used to cost. The memory gate still counts the whole
|
||||
/// file, which is what the decode of one can hold.
|
||||
/// [`preview_window`]); a container the header cannot point into is planned from its tags
|
||||
/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a
|
||||
/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts
|
||||
/// the whole file, which is what the decode of one can hold.
|
||||
pub fn fs_preview_loader() -> PreviewLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
|
||||
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
|
||||
if let Some(image) = preview_window(path, max_edge)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge))
|
||||
{
|
||||
return Some(image);
|
||||
}
|
||||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||||
@@ -706,6 +742,13 @@ pub struct PreviewRead {
|
||||
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
|
||||
/// every preview cost before the header path existed).
|
||||
pub header_path: bool,
|
||||
/// Whether the sparse planner named the ranges, rather than a format's own header fields: the
|
||||
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
|
||||
/// the header path, and on the whole-file fallback).
|
||||
pub planned: bool,
|
||||
/// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise
|
||||
/// (`decode_raw_preview` applies the same declaration): the quick decode applies it too.
|
||||
pub adobe_rgb: bool,
|
||||
}
|
||||
|
||||
impl PreviewRead {
|
||||
@@ -720,7 +763,9 @@ impl PreviewRead {
|
||||
|
||||
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
|
||||
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
|
||||
/// which is the caller's cue to read the file whole.
|
||||
/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps
|
||||
/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest
|
||||
/// that covers it ([`lightcraft_raw::embedded_preview_covering`]).
|
||||
///
|
||||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
|
||||
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
|
||||
@@ -728,7 +773,7 @@ impl PreviewRead {
|
||||
/// still needs the whole container, so the header comes back with the window; for a preview taken
|
||||
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
|
||||
/// whole-file fallback stays in charge.
|
||||
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
pub fn preview_window(path: &str, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
@@ -745,7 +790,13 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
{
|
||||
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
|
||||
}
|
||||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) };
|
||||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else {
|
||||
// The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose
|
||||
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
|
||||
// sparse planner which ranges `embedded_preview` reads before handing the caller the
|
||||
// whole-file fallback.
|
||||
return planned_preview(&mut file, path, total, max_edge);
|
||||
};
|
||||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||||
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
|
||||
@@ -767,7 +818,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true };
|
||||
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false };
|
||||
// On a network share the bytes are the wait, so say what this preview actually cost. A window
|
||||
// taken from the prefix cost nothing beyond the prefix itself.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
@@ -776,6 +827,108 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
Ok(Some((prefix, window, read)))
|
||||
}
|
||||
|
||||
/// The most times the sparse planner may name ranges before the preview gives up: the plan is
|
||||
/// monotone (it never asks for a range twice), so a real file needs a handful.
|
||||
const MAX_PREVIEW_PLAN_ROUNDS: usize = 8;
|
||||
/// Tenths of a file the planner may have read before the preview gives up and the whole-file path
|
||||
/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the
|
||||
/// file has stopped being one.
|
||||
const MAX_PREVIEW_PLAN_SHARE: u64 = 6;
|
||||
/// The largest file the planner path builds its buffer for. The raws it serves are far below this;
|
||||
/// a file past it is read whole as before rather than allocating its length up front.
|
||||
const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30;
|
||||
/// The header the quick path is handed on the planner route. The header route grows its prefix to
|
||||
/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out
|
||||
/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough
|
||||
/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes.
|
||||
const PLAN_PREFIX: u64 = 64 * 1024;
|
||||
|
||||
/// The preview of a container the header-only path cannot answer for, from the byte ranges
|
||||
/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads.
|
||||
///
|
||||
/// The planner is asked about a buffer of the file's whole length in which only the ranges named so
|
||||
/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The
|
||||
/// buffer is the file's length, which the loader's memory gate already counts, and its untouched
|
||||
/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in
|
||||
/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so
|
||||
/// this path's cost is the bytes read, not a promise about RSS.
|
||||
///
|
||||
/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the
|
||||
/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it
|
||||
/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when
|
||||
/// the quick path could not reproduce what the whole-file path shows (the two container facts
|
||||
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
|
||||
/// gives what the whole file gives), so a window that still fails to decode ends in the same
|
||||
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
|
||||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
if total > MAX_PREVIEW_PLAN_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(len) = usize::try_from(total) else { return Ok(None) };
|
||||
let mut buf = vec![0u8; len];
|
||||
let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?;
|
||||
let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) };
|
||||
head.copy_from_slice(&header);
|
||||
let mut loaded = lightcraft_raw::sparse::Ranges::new();
|
||||
loaded.insert(0, header.len() as u64);
|
||||
for _ in 0..MAX_PREVIEW_PLAN_ROUNDS {
|
||||
match lightcraft_raw::sparse::preview_plan(&buf, &loaded) {
|
||||
lightcraft_raw::sparse::HeaderPlan::Need(ranges) => {
|
||||
let wanted = loaded.total().saturating_add(ranges.total());
|
||||
if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) {
|
||||
return Ok(None);
|
||||
}
|
||||
for &(start, end) in ranges.spans() {
|
||||
let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) };
|
||||
let Some(dst) = buf.get_mut(a..b) else { return Ok(None) };
|
||||
file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?;
|
||||
loaded.insert(start, end);
|
||||
}
|
||||
}
|
||||
lightcraft_raw::sparse::HeaderPlan::Ready => {
|
||||
// the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick
|
||||
let edge = u32::try_from(max_edge).unwrap_or(u32::MAX);
|
||||
let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) };
|
||||
// The quick path hands the JPEG to the decoder alone, so what the *container* says
|
||||
// about the preview has to be something the bytes it is handed can show. Two container
|
||||
// facts change the pixels and are not in the JPEG: the preview's colour space (which
|
||||
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
|
||||
// no orientation of its own, the file's EXIF orientation. The colour space is read
|
||||
// here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews,
|
||||
// which used to send each of them to the whole-file path, issue #1440); a container
|
||||
// that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB).
|
||||
// Where the orientation is present and the header cannot answer for it, the
|
||||
// whole-file path keeps the preview.
|
||||
let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb);
|
||||
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
|
||||
// first: a JPEG that carries its own orientation needs nothing from the container.
|
||||
if lightcraft_meta::extract(&window).orientation.is_none()
|
||||
&& lightcraft_meta::extract(&header).orientation.is_none()
|
||||
&& lightcraft_meta::extract(&buf).orientation.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb };
|
||||
// A plan-driven preview is the one that used to cost the whole file, so it is worth
|
||||
// saying what it cost now too.
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len());
|
||||
}
|
||||
// The planner's buffer is the file's length, so give its pages back the way a decode
|
||||
// does (see `fs_pair_loader`).
|
||||
drop(buf);
|
||||
crate::memory::release();
|
||||
return Ok(Some((header, window, read)));
|
||||
}
|
||||
lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
|
||||
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
|
||||
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
|
||||
@@ -807,9 +960,15 @@ fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
|
||||
|
||||
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
|
||||
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
|
||||
/// `None` when the bytes are not a decodable image.
|
||||
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
|
||||
/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied
|
||||
/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image.
|
||||
fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) {
|
||||
lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb)
|
||||
} else {
|
||||
lightcraft_codecs::decode(window, fit_box(max_edge))
|
||||
};
|
||||
let mut d = decoded.ok()?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -856,11 +1015,21 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let _permit = crate::memory::work_gate().acquire(len);
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
probe_bytes(path, &bytes)
|
||||
let mut info = probe_bytes(path, &bytes)?;
|
||||
info.file_modified = file_modified(path);
|
||||
Ok(info)
|
||||
});
|
||||
(loader, probe)
|
||||
}
|
||||
|
||||
/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the
|
||||
/// filesystem doesn't say or the time is before 1970.
|
||||
fn file_modified(path: &str) -> Option<String> {
|
||||
let t = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?;
|
||||
Some(crate::import::civil(secs))
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
|
||||
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
|
||||
@@ -908,28 +1077,49 @@ mod tests {
|
||||
let d65 = lightcraft_color::D65;
|
||||
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
|
||||
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some());
|
||||
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some());
|
||||
let (t, tint) = a7m3.as_shot(d65);
|
||||
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
|
||||
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
|
||||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(bare.legacy_relative && bare.spectral_white.is_none());
|
||||
assert!(bare.legacy_relative && bare.scale_white.is_none());
|
||||
assert_eq!(bare.as_shot(d65), {
|
||||
let (t, tint) = xy_to_temp_tint(d65);
|
||||
(t.round(), tint.round())
|
||||
});
|
||||
// a camera without measured matrices: relative, whatever the multipliers
|
||||
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
|
||||
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none());
|
||||
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none());
|
||||
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
|
||||
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
|
||||
// a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as
|
||||
// 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65)
|
||||
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
|
||||
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
|
||||
(2500.0, g(1336.0, 3672.0)),
|
||||
(3200.0, g(1720.0, 2620.0)),
|
||||
(4500.0, g(2256.0, 1880.0)),
|
||||
(6000.0, g(2680.0, 1544.0)),
|
||||
(8500.0, g(3136.0, 1300.0)),
|
||||
]);
|
||||
let with = ColorData { kelvin_presets: presets, ..Default::default() };
|
||||
let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2"));
|
||||
assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some());
|
||||
let (t, tint) = a99m2.as_shot(d65);
|
||||
assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2"));
|
||||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2"));
|
||||
assert!(bare.legacy_relative && bare.scale_white.is_none());
|
||||
// presets in a file of a camera not listed: relative, as the catalog decides from the camera alone
|
||||
let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1"));
|
||||
assert!(other.relative && other.scale_white.is_none());
|
||||
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
|
||||
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
|
||||
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
|
||||
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
|
||||
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
|
||||
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none());
|
||||
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none());
|
||||
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
|
||||
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||||
@@ -964,15 +1154,16 @@ mod tests {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||||
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview");
|
||||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||||
assert!(read.header_path, "the header path is what answered");
|
||||
assert!(!read.planned, "a RAF pointer in the header needs no planner");
|
||||
assert_eq!(read.total, file.len() as u64);
|
||||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
|
||||
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The quick path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
@@ -980,8 +1171,235 @@ mod tests {
|
||||
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
|
||||
let plain = dir.join("plain.jpg");
|
||||
std::fs::write(&plain, &jpeg).unwrap();
|
||||
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
|
||||
assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the
|
||||
/// file's own orientation, and the EXIF colour space the container declares for its preview.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Fixture {
|
||||
dng: bool,
|
||||
sony: bool,
|
||||
orientation: Option<u16>,
|
||||
/// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB.
|
||||
color_space: Option<u16>,
|
||||
}
|
||||
|
||||
/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the
|
||||
/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of
|
||||
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
|
||||
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
|
||||
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
tiff_with_preview_strips(strip, &[], tail, what)
|
||||
}
|
||||
|
||||
/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its
|
||||
/// 1616 px preview beside the full-size one.
|
||||
fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut preview = IfdBuilder::new();
|
||||
if what.dng {
|
||||
preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||||
}
|
||||
if what.sony {
|
||||
preview.set(t::MAKE, Value::Ascii("SONY".into()));
|
||||
}
|
||||
if let Some(o) = what.orientation {
|
||||
preview.set(t::ORIENTATION, Value::Short(vec![o]));
|
||||
}
|
||||
if let Some(space) = what.color_space {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(0xA001, Value::Short(vec![space]));
|
||||
if space == 0xFFFF {
|
||||
let mut interop = IfdBuilder::new();
|
||||
interop.set(0x0001, Value::Undefined(b"R03".to_vec()));
|
||||
exif.set_child(t::INTEROP_IFD, interop);
|
||||
}
|
||||
preview.set_child(t::EXIF_IFD, exif);
|
||||
}
|
||||
preview.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
|
||||
for jpeg in smaller {
|
||||
let mut sub = IfdBuilder::new();
|
||||
sub.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
sub.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
sub.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] });
|
||||
preview.add_sub_ifd(sub);
|
||||
}
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
|
||||
TiffWriter::default().write(&[preview, raw]).expect("the fixture writes")
|
||||
}
|
||||
|
||||
/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview
|
||||
/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges
|
||||
/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels
|
||||
/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline
|
||||
/// rather than guess, so the caller keeps reading it whole.
|
||||
#[test]
|
||||
fn preview_loading_plans_the_ranges_it_needs() {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan");
|
||||
let path = dir.join("preview.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview");
|
||||
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(read.header_path, "and it is still a bounded read");
|
||||
assert_eq!(read.total, file.len() as u64);
|
||||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len());
|
||||
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
|
||||
|
||||
// The plan path and the whole-file path agree byte for byte.
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||||
|
||||
// The same structure without the tag that makes it a raw: the planner declines, and the
|
||||
// caller's whole-file read is what answers (no guessing at what a file might hold).
|
||||
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
|
||||
std::fs::write(&path, ¬_a_raw).unwrap();
|
||||
assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that
|
||||
/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an
|
||||
/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one.
|
||||
#[test]
|
||||
fn preview_loading_plans_the_smallest_covering_jpeg() {
|
||||
let jpeg = |w: u32, h: u32| {
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect();
|
||||
encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap()
|
||||
};
|
||||
let (big, small) = (jpeg(96, 64), jpeg(48, 32));
|
||||
assert!(big.len() > small.len(), "the full-size JPEG is the largest");
|
||||
let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-covering");
|
||||
let path = dir.join("two-previews.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] {
|
||||
let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(&window == want, "a {edge} px box takes the {which} JPEG");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too");
|
||||
assert_eq!(
|
||||
(quick.width, quick.height, quick.data),
|
||||
(whole.width, whole.height, whole.data),
|
||||
"{edge} px: the plan and the whole file agree"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A file whose preview the quick path could not show the way the whole-file path does is left to
|
||||
/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is
|
||||
/// bigger than that header) and the preview JPEG carries none of its own, so a window read would
|
||||
/// come out unrotated where the whole-file path rotates it — the plan must decline instead of
|
||||
/// handing on a wrong picture.
|
||||
#[test]
|
||||
fn preview_loading_declines_an_orientation_only_the_whole_file_shows() {
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
// the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts
|
||||
// IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image
|
||||
let mut strip = jpeg.clone();
|
||||
strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize));
|
||||
let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-orientation");
|
||||
let path = dir.join("rotated.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
|
||||
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
|
||||
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
|
||||
assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
|
||||
/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies
|
||||
/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and
|
||||
/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one
|
||||
/// that declares sRGB (what most bodies write) keeps the fast path as before.
|
||||
#[test]
|
||||
fn preview_loading_applies_a_container_colour_space() {
|
||||
use lightcraft_raw::PreviewColorSpace;
|
||||
let (w, h) = (48u32, 32u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-space");
|
||||
let path = dir.join("space.arw");
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
|
||||
std::fs::write(&path, &adobe).unwrap();
|
||||
let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too");
|
||||
assert!(read.planned && read.adobe_rgb, "{read:?}");
|
||||
assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}");
|
||||
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes");
|
||||
let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too");
|
||||
assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it");
|
||||
let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes");
|
||||
assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)");
|
||||
|
||||
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
|
||||
std::fs::write(&path, &srgb).unwrap();
|
||||
let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||||
assert!(read.planned && !read.adobe_rgb, "the plan still answers");
|
||||
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
|
||||
}
|
||||
|
||||
/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as
|
||||
/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time.
|
||||
#[test]
|
||||
fn undated_files_take_the_file_date() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let rgb = vec![128u8; 16 * 16 * 3];
|
||||
let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb));
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||||
let path = dir.join("export.jpg");
|
||||
std::fs::write(&path, &jpeg).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
|
||||
let want = crate::import::civil(mtime as i64);
|
||||
let (_, probe) = fs_hooks();
|
||||
let info = probe(&path).unwrap();
|
||||
assert_eq!(info.captured, None, "the file itself has no date");
|
||||
assert_eq!(info.file_modified.as_deref(), Some(want.as_str()));
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r.imported[0]);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1016,6 +1434,17 @@ mod tests {
|
||||
assert!(probe_bytes("x.jpg", b"not an image").is_err());
|
||||
}
|
||||
|
||||
/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets
|
||||
/// start from), not on the black its transparent pixels store.
|
||||
#[test]
|
||||
fn transparent_png_develops_on_white() {
|
||||
let rgba = [200, 40, 40, 255, 0, 0, 0, 0];
|
||||
let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap();
|
||||
let (img, _) = load_bytes(&png, 64).unwrap();
|
||||
assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0));
|
||||
assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0));
|
||||
}
|
||||
|
||||
/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample
|
||||
/// of a block on a grey surface must not become green speckles (the block's green used to take the
|
||||
/// clip level, which highlight reconstruction then kept).
|
||||
@@ -1846,6 +2275,37 @@ mod tests {
|
||||
assert!(e.contains("without an embedded preview"), "{e}");
|
||||
}
|
||||
|
||||
/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606`
|
||||
/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic.
|
||||
#[test]
|
||||
fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let mos = |leaf_data: bool| {
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
|
||||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12]));
|
||||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1]));
|
||||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] });
|
||||
if leaf_data {
|
||||
ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat()));
|
||||
}
|
||||
TiffWriter::default().write(&[ifd0]).unwrap()
|
||||
};
|
||||
let p = probe_bytes("aptus22.mos", &mos(true)).unwrap();
|
||||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30));
|
||||
assert!(p.preview_only.is_some_and(|why| !why.is_empty()));
|
||||
let (img, src) = load_bytes(&mos(true), 20).unwrap();
|
||||
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
|
||||
// the same grey image without Leaf's block is an ordinary 16-bit TIFF
|
||||
let plain = probe_bytes("grey.tif", &mos(false)).unwrap();
|
||||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12));
|
||||
}
|
||||
|
||||
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
|
||||
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
|
||||
#[test]
|
||||
@@ -1919,4 +2379,25 @@ mod tests {
|
||||
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the
|
||||
/// embedded preview can't be read (and nothing is cached) the quick job itself must render the
|
||||
/// raw, or the cell stays empty for good ("no quick preview").
|
||||
#[test]
|
||||
fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() {
|
||||
use crate::media::QuickSource;
|
||||
let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.cr3");
|
||||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None));
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
let q = s.quick_thumb_job(&job).unwrap().run();
|
||||
assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key));
|
||||
assert!(q.rendered.is_ok(), "{:?}", q.rendered.err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+181
-54
@@ -466,10 +466,18 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp
|
||||
|
||||
/// Probe files (headers + content hash) as an import would.
|
||||
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default())
|
||||
let progress = ScanProgress::default();
|
||||
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
|
||||
}
|
||||
|
||||
fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec<Result<ProbeInfo, String>> {
|
||||
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
|
||||
/// `cancel` is set: the files not reached come back as errors.
|
||||
fn probe_all(
|
||||
probe: Option<&crate::media::FileProbe>,
|
||||
paths: &[String],
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Vec<Result<ProbeInfo, String>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let Some(probe) = probe.cloned() else {
|
||||
return paths
|
||||
@@ -497,11 +505,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
if i >= paths.len() {
|
||||
break;
|
||||
}
|
||||
if progress.cancel.load(Relaxed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
let r = probe(&paths[i]);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
done.fetch_add(1, Relaxed);
|
||||
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
|
||||
}
|
||||
});
|
||||
@@ -513,11 +521,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if progress.cancel.load(Relaxed) {
|
||||
if cancel.load(Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let r = probe(p);
|
||||
progress.done.fetch_add(1, Relaxed);
|
||||
done.fetch_add(1, Relaxed);
|
||||
r
|
||||
})
|
||||
.collect()
|
||||
@@ -540,12 +548,62 @@ fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>
|
||||
pub struct ScanInput {
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
skip: Option<PathBuf>,
|
||||
/// path → (photo, its summary) for files already in the library.
|
||||
by_path: HashMap<PathBuf, (PhotoId, ImportCandidate)>,
|
||||
by_hash: HashMap<String, PhotoId>,
|
||||
/// The library as it is now (photos are shared with the session, not copied): looked up for
|
||||
/// the files the scan finds ([`library_files`]) and the contents it probes
|
||||
/// ([`library_hashes`]), on the scan's thread.
|
||||
library: std::sync::Arc<lightcraft_catalog::Catalog>,
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
}
|
||||
|
||||
/// The library's photos of `files`: path → (photo, its summary as a candidate that is there
|
||||
/// already). One pass over the library, and a record only for a file that was found: a folder of
|
||||
/// a hundred files in a library of millions costs a hundred records.
|
||||
fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap<PathBuf, (PhotoId, ImportCandidate)> {
|
||||
let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect();
|
||||
let mut by_path = HashMap::new();
|
||||
if found.is_empty() {
|
||||
return by_path;
|
||||
}
|
||||
for p in library.photos() {
|
||||
if let Source::File { path } = &p.source
|
||||
&& found.contains(Path::new(path))
|
||||
{
|
||||
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(PathBuf::from(path), (p.id, c));
|
||||
}
|
||||
}
|
||||
by_path
|
||||
}
|
||||
|
||||
/// Which photo of the library has each of the contents `hashes` (the later one where several
|
||||
/// have it). One pass over the library, and an entry only for a content that was asked about.
|
||||
fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator<Item = &'a str>) -> HashMap<String, PhotoId> {
|
||||
let wanted: std::collections::HashSet<&str> = hashes.collect();
|
||||
let mut by_hash = HashMap::new();
|
||||
if wanted.is_empty() {
|
||||
return by_hash;
|
||||
}
|
||||
for p in library.photos() {
|
||||
if let Some(h) = &p.content_hash
|
||||
&& wanted.contains(h.as_str())
|
||||
{
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
by_hash
|
||||
}
|
||||
|
||||
/// Progress and cancellation of a running [`scan_with`].
|
||||
#[derive(Default)]
|
||||
pub struct ScanProgress {
|
||||
@@ -565,31 +623,17 @@ pub struct ScanOutput {
|
||||
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(PathBuf::from(path), (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) };
|
||||
// (no table of the library here: this is the session's thread, and the library may hold
|
||||
// millions of photos. The scan looks up what it finds)
|
||||
let library = std::sync::Arc::new(s.catalog.clone());
|
||||
let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) };
|
||||
(input, paths.to_vec())
|
||||
}
|
||||
|
||||
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
|
||||
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
|
||||
std::sync::Arc::clone(&self.library)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
|
||||
@@ -607,7 +651,8 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
|
||||
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(Path::new(f))).cloned().collect();
|
||||
let by_path = library_files(&input.library, &files);
|
||||
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).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
|
||||
@@ -620,16 +665,19 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
progress.done.store(todo.len() - missing.len(), Relaxed);
|
||||
let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter();
|
||||
let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let mut probes: HashMap<String, Result<ProbeInfo, String>> = todo.into_iter().zip(probed).collect();
|
||||
let mut seen_hash: HashMap<String, Option<u64>> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
|
||||
// (who in the library has a content that was probed: asked once the probes are in)
|
||||
let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref());
|
||||
let mut seen_hash: HashMap<String, Option<u64>> =
|
||||
library_hashes(&input.library, probed_hashes).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 {
|
||||
if let Some((_, c)) = input.by_path.get(Path::new(&f)) {
|
||||
if let Some((_, c)) = by_path.get(Path::new(&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);
|
||||
@@ -641,6 +689,8 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
|
||||
Some(Ok(info)) => {
|
||||
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) =
|
||||
(info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone());
|
||||
// as the import will date it (a sidecar's date, when there is one, still wins there)
|
||||
c.captured = c.captured.or_else(|| info.file_modified.clone());
|
||||
c.preview_only = info.preview_only.clone();
|
||||
if let Some(h) = &info.content_hash {
|
||||
match seen_hash.get(h) {
|
||||
@@ -691,6 +741,12 @@ pub struct ImportJob {
|
||||
mode: ImportMode,
|
||||
lib_dir: Option<PathBuf>,
|
||||
copy_root: Option<PathBuf>,
|
||||
/// The library as it was when the import was set up, until it is looked up
|
||||
/// ([`ImportJob::look_up_library`], with the first files): setting an import up is the
|
||||
/// session's thread, and a table of a library of millions of photos takes seconds. With it,
|
||||
/// whether a photo in Recently Deleted is passed over (an import that replaces such photos,
|
||||
/// as the options said when the import was set up).
|
||||
library: Option<(lightcraft_catalog::Catalog, bool)>,
|
||||
/// path → (photo, a Local browse record?)
|
||||
by_path: HashMap<String, (PhotoId, bool)>,
|
||||
/// content hash → the photo that has it (`None`: a file earlier in this import)
|
||||
@@ -705,6 +761,10 @@ pub struct ImportJob {
|
||||
/// Files found so far (folders expanded) and handled so far, for progress.
|
||||
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
/// Of those, the files that have to be read first (not probed by a preceding scan) and the
|
||||
/// ones read so far: the step before they are handled.
|
||||
pub read_total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub read_done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
|
||||
@@ -828,28 +888,16 @@ impl ImportJob {
|
||||
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 by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && opts.on_deleted == OnDeleted::Fresh {
|
||||
continue;
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
let fresh = opts.on_deleted == OnDeleted::Fresh;
|
||||
Ok(ImportJob {
|
||||
seq: opts.rename_start.max(1),
|
||||
opts,
|
||||
mode,
|
||||
lib_dir,
|
||||
copy_root,
|
||||
by_path,
|
||||
by_hash,
|
||||
library: Some((s.catalog.clone(), fresh)),
|
||||
by_path: HashMap::new(),
|
||||
by_hash: HashMap::new(),
|
||||
probe: s.media.file_probe.clone(),
|
||||
file_bytes: s.media.file_bytes.clone(),
|
||||
cache: std::mem::take(&mut s.import_probes),
|
||||
@@ -857,6 +905,8 @@ impl ImportJob {
|
||||
now: (s.clock)(),
|
||||
total: Default::default(),
|
||||
done: Default::default(),
|
||||
read_total: Default::default(),
|
||||
read_done: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -882,9 +932,28 @@ impl ImportJob {
|
||||
&self.now
|
||||
}
|
||||
|
||||
/// What the library held when the import was set up, by path and by content: made once, with
|
||||
/// the first files (on the thread that prepares them), from the library as it was then.
|
||||
fn look_up_library(&mut self) {
|
||||
let Some((library, fresh)) = self.library.take() else { return };
|
||||
for p in library.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && fresh {
|
||||
continue;
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
self.by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
self.by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
self.look_up_library();
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
@@ -917,8 +986,10 @@ impl ImportJob {
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
// Reading the files is the slow part of adding them where they are: it is counted on its
|
||||
// own, and `cancel` stops it too (a large library takes long enough to want both).
|
||||
self.read_total.fetch_add(missing.len(), Relaxed);
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
@@ -974,6 +1045,10 @@ impl ImportJob {
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
// no date in the file or its sidecar: the file's own date, as Lightroom does
|
||||
if info.captured.is_none() {
|
||||
info.captured = info.file_modified.clone();
|
||||
}
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
@@ -1457,6 +1532,7 @@ impl Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
@@ -1472,6 +1548,10 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "import/tests_known.rs"]
|
||||
mod tests_known;
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
@@ -1498,4 +1578,51 @@ mod prepared_tests {
|
||||
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
|
||||
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
|
||||
}
|
||||
|
||||
fn unread_paths(n: usize) -> Vec<String> {
|
||||
(0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect()
|
||||
}
|
||||
|
||||
/// Reading the files of a large import (a Lightroom catalog's originals) reported nothing:
|
||||
/// the progress stood still at "14 of 24554" for as long as it took.
|
||||
#[test]
|
||||
fn prepare_counts_the_files_it_reads() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut s = Session::new();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)));
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
|
||||
let (done, last) = (job.read_done.clone(), done_in_probe.clone());
|
||||
job.probe = Some(std::sync::Arc::new(move |_: &str| {
|
||||
seen_in_probe.fetch_add(1, Relaxed);
|
||||
last.fetch_max(done.load(Relaxed), Relaxed);
|
||||
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
|
||||
}));
|
||||
let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false));
|
||||
assert_eq!(prepared.items.len(), 40);
|
||||
assert_eq!(seen.load(Relaxed), 40);
|
||||
assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read");
|
||||
assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted");
|
||||
assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own");
|
||||
}
|
||||
|
||||
/// Cancel had no effect until every file had been read.
|
||||
#[test]
|
||||
fn cancel_stops_reading_files() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut s = Session::new();
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
|
||||
let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone());
|
||||
job.probe = Some(std::sync::Arc::new(move |_: &str| {
|
||||
seen_in_probe.fetch_add(1, Relaxed);
|
||||
cancel_in_probe.store(true, Relaxed);
|
||||
Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() })
|
||||
}));
|
||||
let prepared = job.prepare(&unread_paths(400), &cancel);
|
||||
// at most the files the (up to 8) readers had already started
|
||||
assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed));
|
||||
assert!(prepared.items.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! What a scan or an import knows of the library: worked out for the files it meets, and not on
|
||||
//! the session's thread. A library can hold millions of photos; looking at one folder must not
|
||||
//! cost a table of them all before the first file is read.
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Catalog, Op};
|
||||
|
||||
/// A library of `folders` folders of three photos each, the third a copy (by content) of a
|
||||
/// photo in the folder before.
|
||||
fn library(folders: usize) -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for folder in 0..folders {
|
||||
for (n, name) in ["a.jpg", "b.jpg", "c.jpg"].into_iter().enumerate() {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "JPEG", 6, 4, "2026-01-01");
|
||||
p.content_hash = Some(format!("{:032x}", if n == 2 { folder.saturating_sub(1) * 3 } else { folder * 3 + n }));
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Given a library of many folders, when a scan meets the files of one of them (and one the
|
||||
/// library does not have), then what it knows of the library is those files' photos, each as the
|
||||
/// library has it, and nothing of the other folders.
|
||||
#[test]
|
||||
fn a_scan_knows_the_library_photos_of_the_files_it_found() {
|
||||
let c = library(100);
|
||||
let files: Vec<String> = ["/pics/007/a.jpg", "/pics/007/b.jpg", "/pics/007/new.jpg", "/pics/007//c.jpg"].map(String::from).to_vec();
|
||||
let known = library_files(&c, &files);
|
||||
assert_eq!(known.len(), 3, "the three the library has (a path is compared as a path, not as text)");
|
||||
let (id, as_found) = known.get(Path::new("/pics/007/b.jpg")).expect("b");
|
||||
let p = c.photo(*id).unwrap();
|
||||
assert_eq!((as_found.path.as_str(), as_found.existing, as_found.duplicate.as_deref()), ("/pics/007/b.jpg", Some(id.0), Some("path")));
|
||||
assert_eq!((as_found.format.as_str(), as_found.width, as_found.height, as_found.file_size), (p.format.as_str(), p.width, p.height, p.file_size));
|
||||
assert!(library_files(&c, &[]).is_empty());
|
||||
// two photos of one file (a virtual copy): the later one is the one named, as a table of the
|
||||
// whole library named it
|
||||
let mut c = c;
|
||||
let copy = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(copy, Source::File { path: "/pics/007/a.jpg".into() }, "a.jpg", "JPEG", 6, 4, "2026-01-02")) })
|
||||
.unwrap();
|
||||
assert_eq!(library_files(&c, &files).get(Path::new("/pics/007/a.jpg")).map(|(id, _)| *id), Some(copy));
|
||||
}
|
||||
|
||||
/// Given a library and a folder on disk that holds a file the library has by its path, one it
|
||||
/// has by its content under another name, and a new one, when the folder is scanned (the whole
|
||||
/// scan, as the import review runs it), then each is found for what it is.
|
||||
#[test]
|
||||
fn a_scan_finds_what_the_library_has_by_path_and_by_content() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-known-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
for name in ["here.jpg", "copy.jpg", "new.jpg"] {
|
||||
std::fs::write(dir.join(name), name.as_bytes()).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.catalog = library(20);
|
||||
let here = s.catalog.alloc_photo_id();
|
||||
s.catalog
|
||||
.apply(Op::AddPhoto { photo: Box::new(Photo::new(here, Source::File { path: at("here.jpg") }, "here.jpg", "JPEG", 6, 4, "2026-01-01")) })
|
||||
.unwrap();
|
||||
// (the probe: every file is a picture whose content is its name; `copy.jpg` has the content of a library photo)
|
||||
let had = s.catalog.photos().find(|p| p.file_name == "b.jpg").map(|p| (p.id, p.content_hash.clone().unwrap())).unwrap();
|
||||
let shared = had.1.clone();
|
||||
s.media.file_probe = Some(std::sync::Arc::new(move |path: &str| {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let content_hash = Some(if name == "copy.jpg" { shared.clone() } else { format!("hash-of-{name}") });
|
||||
Ok(ProbeInfo { format: "JPEG".into(), width: 6, height: 4, file_size: 8, content_hash, ..Default::default() })
|
||||
}));
|
||||
let found = scan(&mut s, &[dir.to_string_lossy().to_string()]);
|
||||
let of = |name: &str| found.iter().find(|c| c.name == name).map(|c| (c.duplicate.as_deref(), c.existing)).unwrap();
|
||||
assert_eq!(of("here.jpg"), (Some("path"), Some(here.0)));
|
||||
assert_eq!(of("copy.jpg"), (Some("content"), Some(had.0.0)));
|
||||
assert_eq!(of("new.jpg"), (None, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Given the hashes of the files a scan probed, when the library is asked who has them, then it
|
||||
/// answers for those hashes only; and where two photos have one content, the same one of them
|
||||
/// as a table of the whole library would name (the later one).
|
||||
#[test]
|
||||
fn a_scan_knows_who_has_the_contents_it_probed() {
|
||||
let c = library(100);
|
||||
// (the content of /pics/006/a.jpg, which /pics/007/c.jpg has too; and one nobody has)
|
||||
let shared = format!("{:032x}", 6 * 3);
|
||||
let nobody = format!("{:032x}", 999_999);
|
||||
let known = library_hashes(&c, [shared.as_str(), nobody.as_str()].into_iter());
|
||||
assert_eq!(known.len(), 1);
|
||||
let whole: HashMap<String, PhotoId> = c.photos().filter_map(|p| p.content_hash.clone().map(|h| (h, p.id))).collect();
|
||||
assert_eq!(known.get(&shared), whole.get(&shared));
|
||||
assert!(matches!(&c.photo(known[&shared]).unwrap().source, Source::File { path } if path == "/pics/007/c.jpg"));
|
||||
}
|
||||
|
||||
/// Given a session with a library, when an import is set up, then no table of the library is
|
||||
/// made on the spot (that is the session's thread); it is made when the first files are
|
||||
/// prepared, and then finds a file the library has by its path and one it has by its content.
|
||||
#[test]
|
||||
fn an_import_looks_the_library_up_when_it_prepares_files() {
|
||||
let mut s = Session::new();
|
||||
s.catalog = library(50);
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
|
||||
assert!(job.by_path.is_empty() && job.by_hash.is_empty() && job.library.is_some(), "nothing is looked up yet");
|
||||
let out = job.prepare_files(vec!["/pics/007/a.jpg".into()], &std::sync::atomic::AtomicBool::new(false));
|
||||
assert!(job.library.is_none(), "looked up once");
|
||||
assert_eq!((job.by_path.len(), job.by_hash.len()), (150, 100));
|
||||
assert!(matches!(out.items.as_slice(), [PreparedItem::Duplicate { reason: "path", existing: Some(_), .. }]), "found by its path");
|
||||
// what the options said when the import was set up decides, whatever is made of them after
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted: OnDeleted::Fresh, ..ImportOptions::default() }).unwrap();
|
||||
job.opts.on_deleted = OnDeleted::Skip;
|
||||
job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
|
||||
assert_eq!(job.by_path.len(), 149, "the trashed photo was passed over, as set up");
|
||||
}
|
||||
|
||||
/// Given a photo in Recently Deleted and an import that replaces such photos with a fresh
|
||||
/// import, when the library is looked up, then that photo is no duplicate (as before the lookup
|
||||
/// moved to the first files).
|
||||
#[test]
|
||||
fn a_trashed_photo_is_not_looked_up_when_imports_replace_it() {
|
||||
let mut s = Session::new();
|
||||
s.catalog = library(2);
|
||||
// (a photo whose content is its own: nobody else in this library has it)
|
||||
let id = s.catalog.photos().find(|p| p.file_name == "b.jpg").unwrap().id;
|
||||
s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
|
||||
for (on_deleted, expected) in [(OnDeleted::Fresh, 5), (OnDeleted::Skip, 6)] {
|
||||
let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted, ..ImportOptions::default() }).unwrap();
|
||||
job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
|
||||
assert_eq!(job.by_path.len(), expected, "{on_deleted:?}");
|
||||
let its_content = s.catalog.photo(id).and_then(|p| p.content_hash.clone()).unwrap();
|
||||
assert_eq!(job.by_hash.contains_key(&its_content), on_deleted == OnDeleted::Skip, "{on_deleted:?}");
|
||||
}
|
||||
}
|
||||
@@ -223,11 +223,12 @@ pub(crate) fn rollback(p: &Placed) {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// destination is still there with the same bytes. 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> {
|
||||
// compare the bytes now: either file may have changed since the copy was verified
|
||||
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(),
|
||||
(Ok(s), Ok(d)) => s.is_file() && d.is_file() && same_bytes(&p.src, &p.dst).unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
|
||||
@@ -41,14 +41,25 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient
|
||||
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
|
||||
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
|
||||
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
|
||||
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
|
||||
let crop =
|
||||
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
|
||||
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
|
||||
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
|
||||
let corr = lens.correction(&shot)?;
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
|
||||
}
|
||||
|
||||
/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the
|
||||
/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C /
|
||||
/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor
|
||||
/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the
|
||||
/// difference is the tag's whole-millimetre rounding and the body's value stands.
|
||||
fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -> Option<f64> {
|
||||
let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c));
|
||||
match (camera, frame) {
|
||||
(Some(body), Some(frame)) if frame > body * 1.1 => Some(frame),
|
||||
(body, frame) => body.or(frame),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`.
|
||||
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
|
||||
crate::files::embedded_lens(raw).or_else(|| {
|
||||
@@ -98,6 +109,46 @@ mod tests {
|
||||
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
}
|
||||
|
||||
/// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the
|
||||
/// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's
|
||||
/// corners don't get the full frame's corner correction.
|
||||
#[test]
|
||||
fn in_camera_crop_mode_uses_the_frames_crop_factor() {
|
||||
let db = LensDb::parse(
|
||||
r#"<lensdatabase>
|
||||
<camera><maker>Sony</maker><model>ILCE-1M2</model><mount>Sony E</mount><cropfactor>1</cropfactor></camera>
|
||||
<lens><maker>Sigma</maker><model>24-70mm F2.8 DG DN | Art 019</model><mount>Sony E</mount><cropfactor>1.0</cropfactor>
|
||||
<calibration>
|
||||
<distortion model="ptlens" focal="70" a="0.0019947" b="0.0035263" c="0.0005016" />
|
||||
<vignetting model="pa" focal="70" aperture="2.8" distance="1000" k1="-0.4" k2="0" k3="0" />
|
||||
</calibration>
|
||||
</lens></lensdatabase>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let m = |f35: Option<f64>| lightcraft_meta::Metadata {
|
||||
make: Some("SONY".into()),
|
||||
model: Some("ILCE-1M2".into()),
|
||||
lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()),
|
||||
focal_length: Some(70.0),
|
||||
focal_length_35mm: f35,
|
||||
f_number: Some(2.8),
|
||||
..Default::default()
|
||||
};
|
||||
let at = |crop: f64, w: f64, h: f64| {
|
||||
let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h };
|
||||
db.lenses[0].correction(&shot).unwrap()
|
||||
};
|
||||
let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap();
|
||||
assert_eq!(aps_c, at(1.5, 5616.0, 3744.0));
|
||||
assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame");
|
||||
// full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor
|
||||
for f35 in [Some(70.0), Some(71.0), None] {
|
||||
assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}");
|
||||
}
|
||||
assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's");
|
||||
assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored");
|
||||
}
|
||||
|
||||
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
|
||||
/// stage with straight lines.
|
||||
#[test]
|
||||
|
||||
@@ -29,8 +29,10 @@ pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
mod errors;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
pub mod face_tiles;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_index;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -68,6 +70,7 @@ pub mod walk;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use errors::{AiModel, ErrorKind};
|
||||
pub use fonts::{CRAFT_FONTS, CraftFont};
|
||||
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
@@ -98,6 +101,13 @@ pub enum EngineError {
|
||||
/// to import with `library.importLightroom`, when one was found.
|
||||
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
|
||||
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
|
||||
/// The work was cancelled (by the person, an agent or a newer request): not a failure.
|
||||
#[error("{0}")]
|
||||
Cancelled(String),
|
||||
/// The feature needs `model`, which is not installed (or still downloading): a frontend
|
||||
/// offers its download (terms first) on [`ErrorKind::ModelMissing`], not on the message.
|
||||
#[error("{msg}")]
|
||||
ModelMissing { model: AiModel, msg: String },
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
@@ -130,7 +140,8 @@ fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. } => Some(*id),
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetHistoryCursor { id, .. } => Some(*id),
|
||||
Op::Batch { ops } if depth < 8 => {
|
||||
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
|
||||
let first = ids.next()??;
|
||||
@@ -221,7 +232,8 @@ pub struct Session {
|
||||
pub previous_active: Option<PhotoId>,
|
||||
/// Groups last used for Copy (Lightroom remembers them).
|
||||
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
|
||||
pub presets: Vec<lightcraft_develop::Preset>,
|
||||
/// Built-in then user presets; its generation tells savers and caches when it changed.
|
||||
pub presets: presets::PresetList,
|
||||
/// Favourite profile ids (persisted with the library, like preset favourites).
|
||||
pub profile_favorites: Vec<String>,
|
||||
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
|
||||
@@ -354,7 +366,7 @@ impl Session {
|
||||
library_folder: None,
|
||||
previous_active: None,
|
||||
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
|
||||
presets: presets::builtin(),
|
||||
presets: presets::PresetList::new(presets::builtin()),
|
||||
profile_favorites: Vec::new(),
|
||||
profile_recent: Vec::new(),
|
||||
journal: Vec::new(),
|
||||
@@ -881,11 +893,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
if self.source == LibrarySource::RecentlyAdded {
|
||||
// newest import first, whatever the sort (the grid groups by import day)
|
||||
// newest import first, whatever the sort direction (the grid groups by import day);
|
||||
// a batch stays in file order (name, then id)
|
||||
let cat = &self.catalog;
|
||||
visible.sort_by(|a, b| {
|
||||
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
|
||||
key(b).cmp(&key(a)).then(a.cmp(b))
|
||||
let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str());
|
||||
let by_name = match (name(a), name(b)) {
|
||||
(Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
};
|
||||
key(b).cmp(&key(a)).then(by_name).then(a.cmp(b))
|
||||
});
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
@@ -993,12 +1011,17 @@ mod tests_face_models;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests_face_recognize;
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests_face_tiles;
|
||||
#[cfg(test)]
|
||||
mod tests_filesystem_history;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
#[cfg(test)]
|
||||
mod tests_history_cursor;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
@@ -1019,6 +1042,8 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_presets_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
#[cfg(test)]
|
||||
mod tests_segment;
|
||||
|
||||
@@ -88,7 +88,8 @@ pub struct Library {
|
||||
retry_at: Option<web_time::Instant>,
|
||||
/// What forgetting untouched Local records did when the library opened.
|
||||
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
|
||||
presets_written: String,
|
||||
/// What presets.json holds (see [`PresetsSaved`]).
|
||||
presets_saved: PresetsSaved,
|
||||
view_written: Vec<u8>,
|
||||
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
|
||||
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
|
||||
@@ -123,6 +124,53 @@ struct PresetsFile {
|
||||
profile_recent: Vec<String>,
|
||||
}
|
||||
|
||||
/// The state presets.json was last written (or read) from. Saving checks the preset list's
|
||||
/// generation and the small profile lists before serializing anything: with a 10 000-preset
|
||||
/// collection presets.json is ~40 MB, and serializing it after every command made each slider
|
||||
/// tick take seconds.
|
||||
#[derive(Default)]
|
||||
struct PresetsSaved {
|
||||
generation: u64,
|
||||
profile_favorites: Vec<String>,
|
||||
profile_recent: Vec<String>,
|
||||
/// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten.
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
impl PresetsSaved {
|
||||
fn state_of(s: &Session) -> PresetsSaved {
|
||||
PresetsSaved {
|
||||
generation: s.presets.generation(),
|
||||
profile_favorites: s.profile_favorites.clone(),
|
||||
profile_recent: s.profile_recent.clone(),
|
||||
hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` changed its presets or profile lists since this state.
|
||||
fn differs(&self, s: &Session) -> bool {
|
||||
self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_hash(b: &[u8]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
b.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static PRESETS_SERIALIZED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times this thread serialized the preset list (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn presets_serialized() -> usize {
|
||||
PRESETS_SERIALIZED.with(|c| c.get())
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct ViewFile {
|
||||
@@ -187,6 +235,8 @@ struct PrefsFile {
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
#[cfg(test)]
|
||||
PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1));
|
||||
let presets = &s.presets;
|
||||
let f = PresetsFile {
|
||||
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
|
||||
@@ -414,8 +464,9 @@ impl Session {
|
||||
for p in &mut self.presets {
|
||||
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||
}
|
||||
let mut ids: std::collections::HashSet<String> = self.presets.iter().map(|p| p.id.clone()).collect();
|
||||
for u in f.user {
|
||||
if !self.presets.iter().any(|p| p.id == u.id) {
|
||||
if ids.insert(u.id.clone()) {
|
||||
self.presets.push(u);
|
||||
}
|
||||
}
|
||||
@@ -447,7 +498,8 @@ impl Session {
|
||||
{
|
||||
self.selection = Selection::single(*first);
|
||||
}
|
||||
let presets_written = presets_json(self);
|
||||
// presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes)
|
||||
let presets_saved = PresetsSaved::state_of(self);
|
||||
// photo ids are per library: drop decoded sources of the previous one
|
||||
self.media.clear_sources();
|
||||
if on_disk {
|
||||
@@ -464,7 +516,7 @@ impl Session {
|
||||
unsaved_error: None,
|
||||
retry_at: None,
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
presets_saved,
|
||||
view_written,
|
||||
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
|
||||
warnings_reported: 0,
|
||||
@@ -526,14 +578,21 @@ impl Session {
|
||||
log::error!("library: compaction: {e}");
|
||||
lib.last_error = Some(format!("compaction: {e}"));
|
||||
}
|
||||
let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json"));
|
||||
if !due {
|
||||
return Ok(());
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let mut state = PresetsSaved::state_of(self);
|
||||
state.hash = bytes_hash(presets.as_bytes());
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
|
||||
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
log::error!("library: presets: {e}");
|
||||
} else {
|
||||
lib.presets_written = presets;
|
||||
}
|
||||
if state.hash == lib.presets_saved.hash {
|
||||
lib.presets_saved = state;
|
||||
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
// the state stays stale: the next save tries again
|
||||
log::error!("library: presets: {e}");
|
||||
} else {
|
||||
lib.presets_saved = state;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ fn error(message: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
|
||||
}
|
||||
|
||||
/// The import was cancelled ([`crate::ErrorKind::Cancelled`]: not a failure). The message is the
|
||||
/// one it always had, so control-channel and MCP clients see the same `error` text.
|
||||
fn cancelled() -> crate::EngineError {
|
||||
crate::EngineError::Cancelled(error("Lightroom import cancelled").to_string())
|
||||
}
|
||||
|
||||
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
|
||||
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
|
||||
if archive_dir.is_some() && archive_path.is_none() {
|
||||
@@ -129,6 +135,65 @@ pub struct LightroomCompletion {
|
||||
/// Alias used by hosts that call the result a commit.
|
||||
pub type LightroomCommit = LightroomCompletion;
|
||||
|
||||
/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LightroomStep {
|
||||
/// Reading the catalog's tables.
|
||||
Catalog,
|
||||
/// Reading the photos the catalog names (headers, content hash): nearly all of the time.
|
||||
ReadPhotos,
|
||||
/// Readying the photos that were read for the catalog.
|
||||
AddPhotos,
|
||||
}
|
||||
|
||||
/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LightroomProgress {
|
||||
/// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]).
|
||||
pub catalog_total: Arc<AtomicUsize>,
|
||||
pub catalog_done: Arc<AtomicUsize>,
|
||||
read_total: Arc<AtomicUsize>,
|
||||
read_done: Arc<AtomicUsize>,
|
||||
add_total: Arc<AtomicUsize>,
|
||||
add_done: Arc<AtomicUsize>,
|
||||
/// The catalog is read: the photos are next.
|
||||
photos: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LightroomProgress {
|
||||
/// Reading the photos is nearly all of an import's time; the catalog and readying the photos
|
||||
/// take the rest of [`LightroomProgress::fraction`].
|
||||
const CATALOG_SHARE: f32 = 0.05;
|
||||
const READ_SHARE: f32 = 0.85;
|
||||
|
||||
/// The step under way, with how much of it is done and its total (0 while not yet known).
|
||||
pub fn step(&self) -> (LightroomStep, usize, usize) {
|
||||
let load = |n: &AtomicUsize| n.load(Ordering::Relaxed);
|
||||
if !self.photos.load(Ordering::Relaxed) {
|
||||
return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total));
|
||||
}
|
||||
let (read_done, read_total) = (load(&self.read_done), load(&self.read_total));
|
||||
let (add_done, add_total) = (load(&self.add_done), load(&self.add_total));
|
||||
// nothing to read (every photo is already in the library) goes straight to adding
|
||||
if read_done < read_total || (read_total == 0 && add_done == 0) {
|
||||
(LightroomStep::ReadPhotos, read_done, read_total)
|
||||
} else {
|
||||
(LightroomStep::AddPhotos, add_done, add_total)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole preparation, 0..=1.
|
||||
pub fn fraction(&self) -> f32 {
|
||||
let (step, done, total) = self.step();
|
||||
let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) };
|
||||
match step {
|
||||
LightroomStep::Catalog => Self::CATALOG_SHARE * part,
|
||||
LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part,
|
||||
LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lightroom import detached from the owner thread.
|
||||
pub struct LightroomJob {
|
||||
path: PathBuf,
|
||||
@@ -136,6 +201,7 @@ pub struct LightroomJob {
|
||||
import: ImportJob,
|
||||
token: LightroomLibraryToken,
|
||||
archive_dir: Option<PathBuf>,
|
||||
progress: LightroomProgress,
|
||||
}
|
||||
|
||||
impl LightroomJob {
|
||||
@@ -145,33 +211,25 @@ impl LightroomJob {
|
||||
let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() };
|
||||
let import = ImportJob::new(s, opts)?;
|
||||
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
|
||||
let progress = LightroomProgress {
|
||||
read_total: Arc::clone(&import.read_total),
|
||||
read_done: Arc::clone(&import.read_done),
|
||||
add_total: Arc::clone(&import.total),
|
||||
add_done: Arc::clone(&import.done),
|
||||
..LightroomProgress::default()
|
||||
};
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress })
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.import.total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.import.done.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(self.done(), self.total())
|
||||
/// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs.
|
||||
pub fn progress(&self) -> LightroomProgress {
|
||||
self.progress.clone()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.total)
|
||||
}
|
||||
|
||||
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.done)
|
||||
}
|
||||
|
||||
/// Finish an owner commit's deferred archive-index write on a worker.
|
||||
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
|
||||
finalization.finish()
|
||||
@@ -180,16 +238,15 @@ impl LightroomJob {
|
||||
/// Read/probe/stage all source data off the owner thread.
|
||||
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
return Err(cancelled());
|
||||
}
|
||||
self.import.total.store(1, Ordering::Relaxed);
|
||||
self.import.done.store(0, Ordering::Relaxed);
|
||||
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
|
||||
// (reading stops with a message when `cancel` is set: the flag says it was cancelled)
|
||||
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done)
|
||||
.map_err(|e| if cancel.load(Ordering::Relaxed) { cancelled() } else { error(e) })?;
|
||||
lightroom_catalog::validate_for_job(&data).map_err(error)?;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
return Err(cancelled());
|
||||
}
|
||||
self.import.done.fetch_add(1, Ordering::Relaxed);
|
||||
let paths: Vec<String> = data
|
||||
.photos
|
||||
.iter()
|
||||
@@ -203,10 +260,13 @@ impl LightroomJob {
|
||||
.filter(|photo| !Path::new(&photo.path).is_file())
|
||||
.map(|photo| normalize(&photo.path))
|
||||
.collect();
|
||||
// the catalog step's last unit (validation, and looking for each original), then the photos
|
||||
self.progress.catalog_done.fetch_add(1, Ordering::Relaxed);
|
||||
self.progress.photos.store(true, Ordering::Relaxed);
|
||||
let normal = self.import.prepare(&paths, cancel);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
return Err(cancelled());
|
||||
}
|
||||
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
|
||||
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
|
||||
@@ -229,7 +289,7 @@ impl LightroomJob {
|
||||
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
|
||||
if prepared.cancelled {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
return Err(cancelled());
|
||||
}
|
||||
if !prepared.token.matches(s) {
|
||||
prepared.normal.rollback();
|
||||
@@ -335,16 +395,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One count for the whole import read "14 of 24554" while the photos were read, then twice
|
||||
/// the number of photos: each step has its own count, and the whole only moves forward.
|
||||
#[test]
|
||||
fn progress_names_each_step_with_its_own_count() {
|
||||
let p = LightroomProgress::default();
|
||||
let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed);
|
||||
assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0));
|
||||
assert_eq!(p.fraction(), 0.0);
|
||||
set(&p.catalog_total, 14);
|
||||
set(&p.catalog_done, 7);
|
||||
assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14));
|
||||
let mut last = p.fraction();
|
||||
assert!(last > 0.0);
|
||||
let mut forward = |p: &LightroomProgress| {
|
||||
let f = p.fraction();
|
||||
assert!(f >= last && f <= 1.0, "{f} after {last}");
|
||||
last = f;
|
||||
};
|
||||
set(&p.catalog_done, 14);
|
||||
p.photos.store(true, Ordering::Relaxed);
|
||||
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet");
|
||||
forward(&p);
|
||||
set(&p.add_total, 100);
|
||||
set(&p.read_total, 100);
|
||||
set(&p.read_done, 40);
|
||||
assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100));
|
||||
forward(&p);
|
||||
set(&p.read_done, 100);
|
||||
set(&p.add_done, 25);
|
||||
assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100));
|
||||
forward(&p);
|
||||
set(&p.add_done, 100);
|
||||
forward(&p);
|
||||
assert!(p.fraction() > 0.999);
|
||||
// every photo already in the library: nothing to read
|
||||
let known = LightroomProgress::default();
|
||||
known.photos.store(true, Ordering::Relaxed);
|
||||
set(&known.add_total, 5);
|
||||
set(&known.add_done, 5);
|
||||
assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
|
||||
let mut session = crate::Session::new();
|
||||
let before = session.catalog.to_snapshot();
|
||||
let prepared = cancelled_prepared(&session);
|
||||
assert!(commit_prepared(&mut session, prepared).is_err());
|
||||
let Err(e) = commit_prepared(&mut session, prepared) else { panic!("a cancelled import commits nothing") };
|
||||
assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
|
||||
assert_eq!(session.catalog.to_snapshot(), before);
|
||||
assert!(session.undo.is_empty());
|
||||
}
|
||||
|
||||
/// Cancelling while the catalog is read is [`crate::ErrorKind::Cancelled`] whatever the message
|
||||
/// says, and a failure that is not a cancellation is not one, even when its text says "cancelled".
|
||||
#[test]
|
||||
fn a_cancelled_read_is_cancelled_by_kind() {
|
||||
let mut session = crate::Session::new();
|
||||
let mut job = LightroomJob::new(&mut session, PathBuf::from("/nonexistent/cancelled.lrcat"), false).unwrap();
|
||||
let Err(e) = job.prepare(&AtomicBool::new(true)) else { panic!("cancelled before it started") };
|
||||
assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
|
||||
// the `error` text clients see is unchanged
|
||||
assert_eq!(e.to_string(), "invalid parameters for `library.importLightroom`: Lightroom import cancelled");
|
||||
let Err(e) = job.prepare(&AtomicBool::new(false)) else { panic!("no such catalog") };
|
||||
assert_ne!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_identity_rejects_late_completion_after_reopen() {
|
||||
let mut session = crate::Session::new();
|
||||
|
||||
@@ -395,8 +395,10 @@ fn validate_header_fields(header: &[u8], page_size: usize) -> Result<usize, Stri
|
||||
if usable < 480 {
|
||||
return Err("SQLite usable page size is below 480 bytes".into());
|
||||
}
|
||||
// 0 = no schema yet (file format §1.3.10): a catalog whose tables are all still in its WAL
|
||||
// has that in the main file, and the WAL's own page 1 is checked again with the overlay
|
||||
let schema_format = be_u32(header, 44)?;
|
||||
if !(1..=4).contains(&schema_format) {
|
||||
if !(0..=4).contains(&schema_format) {
|
||||
return Err("unsupported SQLite schema format".into());
|
||||
}
|
||||
Ok(reserved)
|
||||
@@ -941,6 +943,34 @@ mod tests {
|
||||
assert_eq!(table_alias, Some(0));
|
||||
}
|
||||
|
||||
/// A database file with no schema yet has schema format 0 in its header (file format
|
||||
/// §1.3.10): a catalog created in WAL mode whose tables are all still in its `-wal` file (one
|
||||
/// Lightroom has open and hasn't checkpointed) was refused as "unsupported SQLite schema
|
||||
/// format" before the WAL was looked at. It opens, with no tables; other formats stay refused.
|
||||
#[test]
|
||||
fn schema_format_zero_is_an_empty_database() {
|
||||
let header = |format: u32| {
|
||||
let mut main = vec![0u8; 512];
|
||||
main[..16].copy_from_slice(b"SQLite format 3\0");
|
||||
main[16..18].copy_from_slice(&512u16.to_be_bytes());
|
||||
main[18] = 2;
|
||||
main[19] = 2;
|
||||
main[21] = 64;
|
||||
main[22] = 32;
|
||||
main[23] = 32;
|
||||
main[28..32].copy_from_slice(&1u32.to_be_bytes());
|
||||
main[44..48].copy_from_slice(&format.to_be_bytes());
|
||||
main[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
main[100] = 0x0d;
|
||||
main[105..107].copy_from_slice(&512u16.to_be_bytes());
|
||||
main
|
||||
};
|
||||
let db = Database::open_with_wal(header(0), &[]).unwrap();
|
||||
assert!(db.live_tables().unwrap().is_empty());
|
||||
assert!(Database::open_with_wal(header(4), &[]).is_ok());
|
||||
assert_eq!(Database::open_with_wal(header(5), &[]).err().as_deref(), Some("unsupported SQLite schema format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_unrelated_schema_entries_are_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
+242
-22
@@ -62,11 +62,22 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 61;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 62;
|
||||
|
||||
/// 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];
|
||||
|
||||
/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the
|
||||
/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size
|
||||
/// in between would render (and cache) every face again.
|
||||
pub const FACE_EDGE_STEP: usize = 16;
|
||||
|
||||
/// The edge a face tile asked to be `edge` pixels across is rendered at: the next
|
||||
/// [`FACE_EDGE_STEP`], 16..=512.
|
||||
pub fn face_edge(edge: usize) -> usize {
|
||||
edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge())
|
||||
}
|
||||
|
||||
pub fn thumb_bucket(long_edge: usize) -> usize {
|
||||
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
|
||||
}
|
||||
@@ -223,6 +234,13 @@ impl DecodedSource {
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Box<DecodedSource>),
|
||||
/// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of
|
||||
/// the photo open in the loupe is cut from its preview-level source instead of decoding the
|
||||
/// file again (a second or more for a 64 MP raw).
|
||||
Fit {
|
||||
from: Box<DecodedSource>,
|
||||
max_edge: usize,
|
||||
},
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
@@ -251,6 +269,13 @@ impl SourceRef {
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Fit { from, max_edge } => {
|
||||
if from.image.width.max(from.image.height) <= *max_edge {
|
||||
return Ok((**from).clone());
|
||||
}
|
||||
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
|
||||
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
|
||||
}
|
||||
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
|
||||
SourceRef::File { path, max_edge, loader, fallback, denoise } => {
|
||||
let plain = || match loader {
|
||||
@@ -314,6 +339,8 @@ pub struct MediaCache {
|
||||
full: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// How many full-resolution originals to keep (LRU, at least one).
|
||||
full_capacity: usize,
|
||||
/// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget.
|
||||
on_screen: Option<PhotoId>,
|
||||
/// How many previews to keep (LRU).
|
||||
pub preview_capacity: usize,
|
||||
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
|
||||
@@ -391,6 +418,7 @@ impl Default for MediaCache {
|
||||
previews: Vec::new(),
|
||||
full: Vec::new(),
|
||||
full_capacity: 1,
|
||||
on_screen: None,
|
||||
preview_capacity: 0,
|
||||
budget,
|
||||
file_loader: None,
|
||||
@@ -447,6 +475,16 @@ impl MediaCache {
|
||||
self.trim_full();
|
||||
}
|
||||
|
||||
/// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays
|
||||
/// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it
|
||||
/// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once.
|
||||
pub fn set_on_screen(&mut self, id: Option<PhotoId>) {
|
||||
if self.on_screen != id {
|
||||
self.on_screen = id;
|
||||
self.enforce_budget();
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_full(&mut self) {
|
||||
while self.full.len() > self.full_capacity {
|
||||
let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0);
|
||||
@@ -475,15 +513,20 @@ impl MediaCache {
|
||||
|
||||
/// Evict least recently used entries across the source caches and the rendered previews
|
||||
/// until they fit the budget. The newest preview-level source (the photo on screen, or the
|
||||
/// one just decoded) is never evicted here.
|
||||
/// one just decoded) is never evicted here, nor is the original of the photo on screen
|
||||
/// ([`Self::set_on_screen`]).
|
||||
pub fn enforce_budget(&mut self) {
|
||||
let mut held = self.held();
|
||||
// The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB)
|
||||
// nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and
|
||||
// thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step.
|
||||
let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum();
|
||||
let mut held = self.held().saturating_sub(pinned);
|
||||
while held > self.budget {
|
||||
let newest_preview = self.previews.iter().map(|e| e.2).max();
|
||||
let candidates = [
|
||||
self.thumbs.oldest_tick().map(|t| (t, 0)),
|
||||
self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)),
|
||||
self.full.iter().map(|e| e.2).min().map(|t| (t, 2)),
|
||||
self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)),
|
||||
self.rendered.oldest_tick().map(|t| (t, 3)),
|
||||
];
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
@@ -625,6 +668,15 @@ impl MediaCache {
|
||||
if cached && let Some(a) = self.get_source(p.id, level) {
|
||||
return SourceRef::Loaded(Box::new(a));
|
||||
}
|
||||
// a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not
|
||||
// with a denoised picture, which the thumbnail would lose
|
||||
if cached
|
||||
&& level == SourceLevel::Thumb
|
||||
&& self.denoise.spec(p.id).is_none()
|
||||
&& let Some(big) = self.get_source(p.id, SourceLevel::Preview)
|
||||
{
|
||||
return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() };
|
||||
}
|
||||
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
|
||||
let max_edge = level.max_edge().min(match (&p.source, level) {
|
||||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
@@ -700,6 +752,9 @@ pub struct RenderJob {
|
||||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// Render for this monitor profile ([`Self::with_display`]).
|
||||
pub display: Option<Arc<crate::display::Display>>,
|
||||
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
|
||||
/// ([`crate::Session::face_job`]; `None` for every other job).
|
||||
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -779,6 +834,7 @@ impl RenderJob {
|
||||
self.settings = Arc::new(s);
|
||||
self.cache = None;
|
||||
self.view_cache = None;
|
||||
self.preview_cut = None;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -803,7 +859,31 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting
|
||||
/// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace
|
||||
/// it, so a few such panics used to stop every render).
|
||||
pub fn run(self) -> RenderResult {
|
||||
let failed = self.failed(String::new());
|
||||
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed })
|
||||
}
|
||||
|
||||
/// This job's result when it fails with `e`.
|
||||
fn failed(&self, e: String) -> RenderResult {
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_unguarded(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
{
|
||||
@@ -822,6 +902,26 @@ impl RenderJob {
|
||||
display: None,
|
||||
};
|
||||
}
|
||||
// a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough
|
||||
// (the sensor data is never read for it), else rendered from `source` below
|
||||
if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) {
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put_at(self.cache_generation, *key, Arc::new(image.clone()));
|
||||
}
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
|
||||
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
|
||||
@@ -877,18 +977,7 @@ impl RenderJob {
|
||||
display,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
},
|
||||
Err(e) => self.failed(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,7 +1065,25 @@ pub struct QuickJob {
|
||||
}
|
||||
|
||||
impl QuickJob {
|
||||
/// The stand-in, from the first source that has one. A panic inside is this job's failure
|
||||
/// (see [`RenderJob::run`]).
|
||||
pub fn run(self) -> RenderResult {
|
||||
let (request_id, photo, key) = (self.request_id, self.photo, self.key);
|
||||
crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult {
|
||||
request_id,
|
||||
source_key: None,
|
||||
photo,
|
||||
level: SourceLevel::Thumb,
|
||||
key,
|
||||
rendered: Err(e),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_unguarded(self) -> RenderResult {
|
||||
let (photo, key) = (self.photo, self.key);
|
||||
let request_id = self.request_id;
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult {
|
||||
@@ -1120,6 +1227,7 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1171,13 +1279,19 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
|
||||
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
|
||||
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
|
||||
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
|
||||
/// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size
|
||||
/// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's
|
||||
/// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the
|
||||
/// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source
|
||||
/// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the
|
||||
/// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows
|
||||
/// the content: a later launch shows the tile without reading the original.
|
||||
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
|
||||
let p = self.catalog.photo(id)?.clone();
|
||||
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
|
||||
@@ -1195,12 +1309,13 @@ impl crate::Session {
|
||||
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
|
||||
let mut settings = (*p.develop).clone();
|
||||
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
|
||||
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
|
||||
let edge = face_edge(edge);
|
||||
// pixels along the long edge that leave `edge` across the crop
|
||||
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
|
||||
let level = SourceLevel::for_size(needed as usize);
|
||||
let source = self.media.source_ref(&p, level);
|
||||
let ck = Self::variant_key(&p, &settings, edge);
|
||||
let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize);
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
@@ -1217,9 +1332,26 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
preview_cut,
|
||||
})
|
||||
}
|
||||
|
||||
/// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an
|
||||
/// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as
|
||||
/// imported, with a preview loader installed. `rect` is the square around the face, `edge` the
|
||||
/// tile, `needed` the source's long edge the develop render would ask for.
|
||||
fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option<crate::face_tiles::PreviewCut> {
|
||||
let (path, loader) = self.embedded_of(p)?;
|
||||
if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let want = needed.max(edge).max(1);
|
||||
let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize;
|
||||
let aspect = f64::from(p.width) / f64::from(p.height);
|
||||
Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge })
|
||||
}
|
||||
|
||||
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
|
||||
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||||
Hasher128::new()
|
||||
@@ -1355,8 +1487,10 @@ impl crate::Session {
|
||||
|
||||
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
|
||||
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
|
||||
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
|
||||
/// other photos (render them directly).
|
||||
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job`
|
||||
/// itself: the grid queues no render behind an unedited raw's quick thumbnail
|
||||
/// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the
|
||||
/// cell empty (issue #1448). `None` for other photos (render them directly).
|
||||
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option<QuickJob> {
|
||||
let p = self.catalog.photo(job.photo)?.clone();
|
||||
let (path, l) = self.embedded_of(&p)?;
|
||||
@@ -1367,7 +1501,7 @@ impl crate::Session {
|
||||
key: job.key,
|
||||
cached: job.cache.clone().into_iter().collect(),
|
||||
embedded: Some((path, l, edge)),
|
||||
small: None,
|
||||
small: Some(Box::new(job.clone())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1479,6 +1613,10 @@ pub struct ProbeInfo {
|
||||
pub kind: MediaKind,
|
||||
pub file_size: u64,
|
||||
pub captured: Option<String>,
|
||||
/// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the
|
||||
/// capture date of a file whose metadata has none, as Lightroom does (exports saved without
|
||||
/// metadata would otherwise all land under "Unknown Date").
|
||||
pub file_modified: Option<String>,
|
||||
pub meta: lightcraft_catalog::Meta,
|
||||
pub as_shot_wb: Option<(f64, f64)>,
|
||||
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
|
||||
@@ -1588,6 +1726,31 @@ mod thumbnail_hash_tests {
|
||||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0);
|
||||
}
|
||||
/// A panic while rendering (here in the decoder) used to end the pool's worker thread without
|
||||
/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now,
|
||||
/// for renders and for stand-ins, answering the request it came from.
|
||||
#[test]
|
||||
fn a_render_that_panics_fails_instead_of_never_answering() {
|
||||
let mut s = session();
|
||||
s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug")));
|
||||
let mut job = s.thumb_job(PhotoId(1), 256).unwrap();
|
||||
job.request_id = 42;
|
||||
let (key, level, source_key) = (job.key, job.level, job.source_key);
|
||||
let r = job.run();
|
||||
assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key));
|
||||
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug"));
|
||||
let quick = QuickJob {
|
||||
request_id: 7,
|
||||
photo: PhotoId(1),
|
||||
key: 9,
|
||||
cached: Vec::new(),
|
||||
embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option<Rgba8> { panic!("preview reader bug") }), 256)),
|
||||
small: None,
|
||||
};
|
||||
let r = quick.run();
|
||||
assert_eq!((r.request_id, r.key), (7, 9));
|
||||
assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1793,6 +1956,45 @@ mod tests {
|
||||
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
|
||||
}
|
||||
|
||||
// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay
|
||||
// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render)
|
||||
#[test]
|
||||
fn the_on_screen_original_does_not_evict_the_other_sources() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
let mut m = MediaCache::default();
|
||||
let mb = 1 << 20;
|
||||
m.set_budget(20 * mb);
|
||||
m.set_on_screen(Some(PhotoId(1)));
|
||||
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
|
||||
m.insert(PhotoId(1), SourceLevel::Thumb, img(200));
|
||||
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
|
||||
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
|
||||
for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] {
|
||||
assert!(m.has_source(PhotoId(id), level), "{id} {level:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB):
|
||||
// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo
|
||||
// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown.
|
||||
#[test]
|
||||
fn the_original_on_screen_stays_even_over_budget() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
let mut m = MediaCache::default();
|
||||
let mb = 1 << 20;
|
||||
m.set_budget(20 * mb);
|
||||
m.set_on_screen(Some(PhotoId(1)));
|
||||
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
|
||||
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
|
||||
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
|
||||
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
|
||||
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
|
||||
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
|
||||
m.set_on_screen(Some(PhotoId(2)));
|
||||
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
|
||||
assert!(m.held() <= 20 * mb, "{}", m.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_gate_waits_for_room() {
|
||||
use crate::memory::WorkGate;
|
||||
@@ -1931,6 +2133,24 @@ mod tests {
|
||||
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
|
||||
}
|
||||
|
||||
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
|
||||
/// one nothing can decode) is not read again.
|
||||
#[test]
|
||||
fn a_thumbnail_is_cut_from_the_decoded_preview() {
|
||||
let mut s = crate::Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
|
||||
s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
|
||||
let job = s.thumb_job(p.id, 512).unwrap();
|
||||
assert!(matches!(job.source, SourceRef::Fit { .. }));
|
||||
let r = job.run();
|
||||
let thumb = r.rendered.as_ref().unwrap();
|
||||
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
|
||||
s.accept(&r);
|
||||
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
|
||||
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_source_follows_what_the_session_accepted() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn default_budget() -> usize {
|
||||
}
|
||||
|
||||
/// Physical memory (bytes), when the platform tells us without native calls.
|
||||
fn total_ram() -> Option<usize> {
|
||||
pub(crate) fn total_ram() -> Option<usize> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let s = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
|
||||
@@ -110,7 +110,9 @@ impl MergeJob {
|
||||
.par_iter()
|
||||
.map(|(_, path)| {
|
||||
let bytes = (self.read)(path)?;
|
||||
let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}"));
|
||||
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
|
||||
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
|
||||
.map_err(|e| format!("{path}: {e}"));
|
||||
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
|
||||
f
|
||||
@@ -128,11 +130,14 @@ impl MergeJob {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s);
|
||||
// the reference frame's lens corrections (HDR); panoramas carry none
|
||||
let mut lens = Vec::new();
|
||||
let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind {
|
||||
MergeKind::Hdr { hdr } => {
|
||||
let frames = self.load(false, progress, (0.0, 0.3))?;
|
||||
let p = sub(0.3, 0.9);
|
||||
let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
|
||||
let mut r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
|
||||
lens = std::mem::take(&mut r.lens);
|
||||
let info = json!({
|
||||
"reference": self.sources[r.reference].0.0,
|
||||
"ev": r.ev, "evExif": r.ev_exif,
|
||||
@@ -181,6 +186,7 @@ impl MergeJob {
|
||||
orientation: r.orientation,
|
||||
metadata: r.metadata,
|
||||
baseline_exposure: r.baseline_exposure,
|
||||
lens: Vec::new(),
|
||||
};
|
||||
f.orient();
|
||||
merged.push(f);
|
||||
@@ -212,7 +218,7 @@ impl MergeJob {
|
||||
metadata.caption = Some(format!("Merged from {}", names.join(", ")));
|
||||
}
|
||||
metadata.software = Some("LightCraft Photo Merge".into());
|
||||
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?;
|
||||
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples, &lens).map_err(err)?;
|
||||
info["width"] = json!(image.width);
|
||||
info["height"] = json!(image.height);
|
||||
let preview = match self.preview {
|
||||
|
||||
@@ -28,28 +28,45 @@ pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mp
|
||||
|
||||
// ------------------------------------------------------------------------------------- zip
|
||||
|
||||
/// The most a preset zip entry may hold unpacked (a "DNG preset" photo is the largest kind, tens
|
||||
/// of MB): its stated size is the file's word, so a small archive could otherwise ask for 8 GB.
|
||||
pub const MAX_ZIP_ENTRY: usize = 256 << 20;
|
||||
/// The most a whole preset zip may hold unpacked.
|
||||
pub const MAX_ZIP_TOTAL: usize = 1 << 30;
|
||||
|
||||
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
|
||||
/// directories, macOS resource forks and hidden files are skipped.
|
||||
/// directories, macOS resource forks and hidden files are skipped. An entry that unpacks past
|
||||
/// [`MAX_ZIP_ENTRY`], or an archive past [`MAX_ZIP_TOTAL`], is an error (a zip bomb would make
|
||||
/// the allocation fail, which ends the app).
|
||||
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
read_zip_limited(bytes, MAX_ZIP_ENTRY, MAX_ZIP_TOTAL)
|
||||
}
|
||||
|
||||
fn read_zip_limited(bytes: &[u8], max_entry: usize, max_total: usize) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
// offsets from the file, added up without overflowing (a 32-bit build reads them too)
|
||||
let add = |a: usize, b: usize| a.checked_add(b).ok_or("bad zip");
|
||||
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
|
||||
let tail = bytes.len().saturating_sub(65_557);
|
||||
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
|
||||
let count = u16_at(eocd + 10).ok_or("bad zip")?;
|
||||
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
|
||||
let mut out = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for _ in 0..count {
|
||||
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
|
||||
return Err("bad zip central directory".into());
|
||||
}
|
||||
let method = u16_at(at + 10).ok_or("bad zip")?;
|
||||
let csize = u32_at(at + 20).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
|
||||
let local = u32_at(at + 42).ok_or("bad zip")?;
|
||||
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
|
||||
at += 46 + nlen + xlen + clen;
|
||||
let method = u16_at(add(at, 10)?).ok_or("bad zip")?;
|
||||
let csize = u32_at(add(at, 20)?).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(add(at, 24)?).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) =
|
||||
(u16_at(add(at, 28)?).ok_or("bad zip")?, u16_at(add(at, 30)?).ok_or("bad zip")?, u16_at(add(at, 32)?).ok_or("bad zip")?);
|
||||
let local = u32_at(add(at, 42)?).ok_or("bad zip")?;
|
||||
let name_at = add(at, 46)?;
|
||||
let name = String::from_utf8_lossy(bytes.get(name_at..add(name_at, nlen)?).ok_or("bad zip")?).replace('\\', "/");
|
||||
at = add(add(add(name_at, nlen)?, xlen)?, clen)?;
|
||||
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
|
||||
if skip {
|
||||
continue;
|
||||
@@ -57,13 +74,20 @@ pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
|
||||
return Err(format!("{name}: bad local header"));
|
||||
}
|
||||
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
|
||||
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let data_at = add(add(add(local, 30)?, u16_at(add(local, 26)?).ok_or("bad zip")?)?, u16_at(add(local, 28)?).ok_or("bad zip")?)?;
|
||||
let data = bytes.get(data_at..add(data_at, csize)?).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let left = max_total.saturating_sub(total);
|
||||
let limit = max_entry.min(left);
|
||||
let too_big = || format!("{name}: unpacks to more than {} MB, too much for a preset", limit >> 20);
|
||||
let content = match method {
|
||||
0 if data.len() > limit => return Err(too_big()),
|
||||
0 => data.to_vec(),
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1).saturating_mul(2).saturating_add(1024).min(limit)).map_err(
|
||||
|e| if matches!(e.status, miniz_oxide::inflate::TINFLStatus::HasMoreOutput) { too_big() } else { format!("{name}: {e:?}") },
|
||||
)?,
|
||||
m => return Err(format!("{name}: unsupported compression method {m}")),
|
||||
};
|
||||
total = total.saturating_add(content.len());
|
||||
out.push((name, content));
|
||||
}
|
||||
Ok(out)
|
||||
@@ -695,6 +719,33 @@ text]], z = ZSTR "loc" }"#,
|
||||
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bombs_are_refused() {
|
||||
// an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named
|
||||
let zeros = vec![0u8; 3 << 20];
|
||||
let z = zip(&[("Pack/huge.xmp", &zeros)], true);
|
||||
assert!(z.len() < 64 << 10, "a small archive: {} bytes", z.len());
|
||||
let e = read_zip_limited(&z, 1 << 20, 1 << 30).unwrap_err();
|
||||
assert!(e.contains("Pack/huge.xmp") && e.contains("more than 1 MB"), "{e}");
|
||||
// the whole archive counts too
|
||||
let two = zip(&[("a.xmp", &zeros[..1 << 20]), ("b.xmp", &zeros[..1 << 20])], true);
|
||||
assert!(read_zip_limited(&two, 2 << 20, (3 << 20) / 2).is_err());
|
||||
assert_eq!(read_zip_limited(&two, 2 << 20, 3 << 20).unwrap().len(), 2);
|
||||
// and stored entries
|
||||
let stored = zip(&[("s.xmp", &zeros[..2 << 20])], false);
|
||||
assert!(read_zip_limited(&stored, 1 << 20, 1 << 30).is_err());
|
||||
// the size an entry states (here 4 GB) no longer sets how much may be unpacked
|
||||
let mut lying = zip(&[("a.xmp", b"<x:xmpmeta/>")], true);
|
||||
let cd = lying.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
|
||||
lying[cd + 24..cd + 28].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(read_zip(&lying).unwrap()[0].1, b"<x:xmpmeta/>");
|
||||
// offsets near the top of a u32 are an error, not an overflow
|
||||
let mut far = zip(&[("a.xmp", b"x")], false);
|
||||
let cd = far.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
|
||||
far[cd + 42..cd + 46].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(read_zip(&far).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundles_with_folders_as_groups() {
|
||||
for deflate in [false, true] {
|
||||
|
||||
@@ -23,6 +23,73 @@ pub struct PresetFile {
|
||||
pub presets: Vec<Preset>,
|
||||
}
|
||||
|
||||
/// The session's presets (built-in, then the user's) with a generation that changes on every
|
||||
/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
|
||||
/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
|
||||
///
|
||||
/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
|
||||
/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
|
||||
/// Generations are unique across all lists in the process.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PresetList {
|
||||
items: Vec<Preset>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn next_generation() -> u64 {
|
||||
PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
impl PresetList {
|
||||
pub fn new(items: Vec<Preset>) -> PresetList {
|
||||
PresetList { items, generation: next_generation() }
|
||||
}
|
||||
|
||||
/// Changes whenever the list may have changed.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PresetList {
|
||||
fn default() -> PresetList {
|
||||
PresetList::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for PresetList {
|
||||
type Target = Vec<Preset>;
|
||||
fn deref(&self) -> &Vec<Preset> {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for PresetList {
|
||||
fn deref_mut(&mut self) -> &mut Vec<Preset> {
|
||||
self.generation = next_generation();
|
||||
&mut self.items
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a PresetList {
|
||||
type Item = &'a Preset;
|
||||
type IntoIter = std::slice::Iter<'a, Preset>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.items.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a mut PresetList {
|
||||
type Item = &'a mut Preset;
|
||||
type IntoIter = std::slice::IterMut<'a, Preset>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.generation = next_generation();
|
||||
self.items.iter_mut()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
|
||||
pub fn slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
@@ -107,22 +174,33 @@ impl Session {
|
||||
/// Add imported presets. Identical presets (same name, group and settings) already present are
|
||||
/// skipped; id clashes get a fresh id. Returns the ids added.
|
||||
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
|
||||
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
|
||||
let mut by_name: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for (i, q) in self.presets.iter().enumerate() {
|
||||
by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
|
||||
}
|
||||
let mut added = Vec::new();
|
||||
for mut p in presets {
|
||||
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
|
||||
let key = (p.name.clone(), p.group.clone());
|
||||
let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
|
||||
if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
|
||||
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
|
||||
let mut id = base.clone();
|
||||
let mut n = 2;
|
||||
while self.presets.iter().any(|q| q.id == id) {
|
||||
while ids.contains(&id) {
|
||||
id = format!("{base}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
p.id = id;
|
||||
}
|
||||
p.builtin = false;
|
||||
ids.insert(p.id.clone());
|
||||
by_name.entry(key).or_default().push(self.presets.len());
|
||||
added.push(p.id.clone());
|
||||
self.presets.push(p);
|
||||
}
|
||||
|
||||
+88
-17
@@ -319,6 +319,17 @@ pub(crate) trait MoveFs {
|
||||
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
|
||||
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
|
||||
/// The folder lists an entry spelled exactly like `p` (letter case included). On a
|
||||
/// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two
|
||||
/// apart. When the folder can't be listed: `true`, so nothing is skipped.
|
||||
fn listed(&self, p: &Path) -> bool {
|
||||
let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true };
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries.flatten().any(|e| e.file_name() == name),
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
/// Content observed in a packet, independent of its pathname or inode. Model volumes
|
||||
/// override this; disk implementations stream a bounded buffer without retaining the XML.
|
||||
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
|
||||
@@ -366,15 +377,23 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// no stable file id in std here: `b` is `a` under another spelling when they look
|
||||
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
|
||||
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
|
||||
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
|
||||
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
// no stable file id in std here: `a` and `b` are one file under two spellings when
|
||||
// they look alike and the folder doesn't list both (on a case-sensitive folder
|
||||
// holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the
|
||||
// listed one: the answer must not depend on the argument order.
|
||||
let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false };
|
||||
let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase();
|
||||
if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return false };
|
||||
!entries.flatten().any(|e| e.file_name() == name)
|
||||
let (mut listed_a, mut listed_b) = (false, false);
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
listed_a |= name == name_a;
|
||||
listed_b |= name == name_b;
|
||||
}
|
||||
!(listed_a && listed_b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,15 +718,14 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu
|
||||
if fs.exists(t) && !is_respelling(fs, f, t) {
|
||||
return Err(fail(format!("{to} already exists")));
|
||||
}
|
||||
let mut packets: Vec<RetainedPacket> = sidecars(from)
|
||||
.into_iter()
|
||||
.map(|(p, _, _)| p)
|
||||
.filter(|p| fs.is_file(p))
|
||||
.map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path })
|
||||
.collect();
|
||||
// An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the
|
||||
// lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`.
|
||||
let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc));
|
||||
let mut packets: Vec<RetainedPacket> =
|
||||
sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect();
|
||||
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
|
||||
let mut done: Vec<SidecarMove> = Vec::new();
|
||||
let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| {
|
||||
let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| {
|
||||
let mut dst = crate::sidecar::sidecar_path(to, naming);
|
||||
if upper {
|
||||
dst = dst.with_extension("XMP");
|
||||
@@ -1124,6 +1142,24 @@ pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Windows has no file id in std: two spellings of one file are the same file whichever is
|
||||
/// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the
|
||||
/// known `Trip.xmp`), and only the spelling on disk is listed.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn same_file_spellings_are_symmetric_on_windows() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp"));
|
||||
std::fs::write(&real, b"packet").unwrap();
|
||||
std::fs::write(&other, b"packet").unwrap();
|
||||
assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real));
|
||||
assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias));
|
||||
assert!(RealFs.listed(&real) && !RealFs.listed(&alias));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// The cross-volume move's check compares files chunk by chunk: equal files across several
|
||||
/// chunks, a difference in the last chunk, different lengths, a missing file.
|
||||
#[test]
|
||||
@@ -1171,7 +1207,10 @@ pub(crate) mod tests {
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p })
|
||||
self.files.borrow().iter().position(|(f, _)| {
|
||||
let f = f.replace('\\', "/");
|
||||
if self.ci { folded_path(&f) == folded_path(&p) } else { f == p }
|
||||
})
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
pub fn listing(&self) -> Vec<(String, String)> {
|
||||
@@ -1196,7 +1235,7 @@ pub(crate) mod tests {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
@@ -1207,7 +1246,7 @@ pub(crate) mod tests {
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
@@ -1223,6 +1262,10 @@ pub(crate) mod tests {
|
||||
self.files.borrow_mut().remove(i);
|
||||
Ok(())
|
||||
}
|
||||
fn listed(&self, p: &Path) -> bool {
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p)
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = Self::norm(&sidecar.with_extension(""));
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
@@ -1293,7 +1336,7 @@ pub(crate) mod tests {
|
||||
} else {
|
||||
serde_json::json!({"path": from, "into": root.join("Archive")})
|
||||
};
|
||||
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") };
|
||||
let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") };
|
||||
let before = s.catalog.to_snapshot();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
@@ -1421,6 +1464,34 @@ pub(crate) mod tests {
|
||||
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
|
||||
}
|
||||
|
||||
/// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed
|
||||
/// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling
|
||||
/// is the one reported and queued.
|
||||
#[test]
|
||||
fn case_insensitive_renames_keep_each_sidecars_spelling() {
|
||||
let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]);
|
||||
move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap();
|
||||
move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/c.jpg".into(), "A".into()),
|
||||
("/p/c.jpg.XMP".into(), "full".into()),
|
||||
("/p/c.xmp".into(), "stem".into()),
|
||||
("/p/d.jpg".into(), "B".into()),
|
||||
("/p/d.xmp".into(), "mixed".into())
|
||||
]
|
||||
);
|
||||
// the move of the sidecar's real spelling is the one attempted (and here fails): the photo
|
||||
// goes back and nothing changed
|
||||
let before = fs.listing();
|
||||
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into()];
|
||||
let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err();
|
||||
assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}");
|
||||
assert!(!e.moved && e.pending_sidecars.is_empty());
|
||||
assert_eq!(fs.listing(), before);
|
||||
}
|
||||
|
||||
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
|
||||
/// their new names (an undoable partial rename), so nothing goes missing.
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct Status {
|
||||
pub file: String,
|
||||
/// The last download's error (cleared when a new one starts).
|
||||
pub error: Option<String>,
|
||||
/// The last download was cancelled (its `error` says so in words): decide on this, not on the text.
|
||||
pub cancelled: bool,
|
||||
/// The last download finished and the files are in place.
|
||||
pub finished: bool,
|
||||
}
|
||||
@@ -28,6 +30,7 @@ pub struct Status {
|
||||
struct Text {
|
||||
file: String,
|
||||
error: Option<String>,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -68,15 +71,17 @@ impl Downloader {
|
||||
{
|
||||
last.file.clone_from(&t.file);
|
||||
last.error.clone_from(&t.error);
|
||||
last.cancelled = t.cancelled;
|
||||
}
|
||||
// (only the session's thread locks `last`)
|
||||
let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
|
||||
let (file, error, cancelled) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone(), l.cancelled)).unwrap_or_default();
|
||||
Status {
|
||||
running: s.running.load(Ordering::SeqCst),
|
||||
done: s.done.load(Ordering::SeqCst),
|
||||
total: s.total.load(Ordering::SeqCst),
|
||||
file,
|
||||
error,
|
||||
cancelled,
|
||||
finished: s.finished.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
@@ -135,6 +140,7 @@ impl Downloader {
|
||||
};
|
||||
let r =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
|
||||
let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));
|
||||
let error = match r {
|
||||
Ok(Ok(())) => {
|
||||
s.finished.store(true, Ordering::SeqCst);
|
||||
@@ -147,7 +153,10 @@ impl Downloader {
|
||||
if let Some(e) = &error {
|
||||
log::warn!("SAM 3 download: {e}");
|
||||
}
|
||||
lock(&s.text).error = error;
|
||||
let mut text = lock(&s.text);
|
||||
text.cancelled = cancelled;
|
||||
text.error = error;
|
||||
drop(text);
|
||||
drop(guard);
|
||||
});
|
||||
match spawned {
|
||||
|
||||
@@ -35,7 +35,7 @@ pub const MODEL_BYTES: u64 = 3_439_938_512;
|
||||
/// The model's licence (not LightCraft's): shown before downloading.
|
||||
pub const LICENSE_NAME: &str = "SAM License (Meta)";
|
||||
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
|
||||
/// How errors about a missing model start (the UI offers the download on it).
|
||||
/// How errors about a missing model start (frontends decide on [`crate::ErrorKind::ModelMissing`], not on this text).
|
||||
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
|
||||
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
@@ -111,6 +111,8 @@ pub struct DownloadStatus {
|
||||
pub total: u64,
|
||||
pub file: String,
|
||||
pub error: Option<String>,
|
||||
/// The last download was cancelled (it resumes next time): decide on this, not on `error`'s text.
|
||||
pub cancelled: bool,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
@@ -215,7 +217,15 @@ impl Segmenter {
|
||||
#[cfg(feature = "sam")]
|
||||
{
|
||||
let s = self.download.status();
|
||||
DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
|
||||
DownloadStatus {
|
||||
running: s.running,
|
||||
done: s.done,
|
||||
total: s.total,
|
||||
file: s.file,
|
||||
error: s.error,
|
||||
cancelled: s.cancelled,
|
||||
finished: s.finished,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
DownloadStatus::default()
|
||||
@@ -234,27 +244,36 @@ impl Segmenter {
|
||||
|
||||
/// The model folder, if the model is there; otherwise an error that says what to do.
|
||||
pub fn model_dir(&self) -> Result<PathBuf, String> {
|
||||
self.require_model().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// [`Segmenter::model_dir`] for commands: a model that is not installed (or still downloading) is
|
||||
/// [`crate::EngineError::ModelMissing`], so a frontend offers the download without reading the message.
|
||||
pub fn require_model(&self) -> crate::Result<PathBuf> {
|
||||
let other = |why: &str| crate::EngineError::Other(why.into());
|
||||
if !Self::AVAILABLE {
|
||||
return Err("AI masks are not available in this build".into());
|
||||
return Err(other("AI masks are not available in this build"));
|
||||
}
|
||||
#[cfg(feature = "sam")]
|
||||
if lightcraft_segment::remote::configured().is_some() {
|
||||
return Ok(PathBuf::new());
|
||||
}
|
||||
let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
|
||||
let dir = self.dir.clone().ok_or_else(|| other("no folder is set for the SAM 3 model"))?;
|
||||
if self.installed() {
|
||||
return Ok(dir);
|
||||
}
|
||||
let d = self.download_status();
|
||||
if d.running {
|
||||
let msg = if d.running {
|
||||
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
|
||||
return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
|
||||
}
|
||||
Err(format!(
|
||||
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
|
||||
MODEL_BYTES as f64 / 1e9,
|
||||
dir.display()
|
||||
))
|
||||
format!("{NOT_INSTALLED} yet: it is downloading ({pct} %).")
|
||||
} else {
|
||||
format!(
|
||||
"{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
|
||||
MODEL_BYTES as f64 / 1e9,
|
||||
dir.display()
|
||||
)
|
||||
};
|
||||
Err(crate::EngineError::ModelMissing { model: crate::AiModel::Sam3, msg })
|
||||
}
|
||||
|
||||
/// Start downloading the model (on a background thread; see `segment.model.download`).
|
||||
|
||||
@@ -450,6 +450,33 @@ fn stem_key(original: &str) -> String {
|
||||
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
|
||||
}
|
||||
|
||||
/// Where a plain path is cut to make its [`stem_key`]: at the dot of its extension, which is
|
||||
/// what `with_extension` replaces. A plain path is ASCII and ends in its file's name with an
|
||||
/// extension, which is what a library's paths are; `None` for any other shape.
|
||||
fn plain_cut(original: &str) -> Option<usize> {
|
||||
let path = Path::new(original);
|
||||
let (name, stem, ext) = (path.file_name()?.to_str()?, path.file_stem()?.to_str()?, path.extension()?.to_str()?);
|
||||
// (the path ends in its file's name, as it stands: no `/.` or separator after it; and the
|
||||
// name has a stem and an extension of its own, which `..jpg` and `a.` have not)
|
||||
let plain = original.is_ascii() && original.ends_with(name) && !ext.is_empty() && !stem.is_empty() && stem != "." && stem != "..";
|
||||
let cut = original.len().checked_sub(ext.len().saturating_add(1))?;
|
||||
(plain && original.as_bytes().get(cut) == Some(&b'.')).then_some(cut)
|
||||
}
|
||||
|
||||
/// [`stem_key`] written into `key`, which is reused: nothing is allocated for a plain path
|
||||
/// ([`plain_cut`]). Any other shape goes the long way.
|
||||
fn stem_key_into(key: &mut String, original: &str) {
|
||||
key.clear();
|
||||
match plain_cut(original) {
|
||||
Some(cut) => {
|
||||
key.push_str(original.get(..cut).unwrap_or_default());
|
||||
key.make_ascii_lowercase();
|
||||
key.push_str(".xmp");
|
||||
}
|
||||
None => key.push_str(&stem_key(original)),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } => Some(path),
|
||||
@@ -490,8 +517,11 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
|
||||
impl StemOwners {
|
||||
/// The owners of every shared stem in the library, each file's key made the long way: what
|
||||
/// [`StemOwners::among`] must agree with for the photos it is asked about (this is how the
|
||||
/// owners were worked out before, for every write).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
@@ -505,6 +535,39 @@ impl StemOwners {
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// The owners of the stems photos `ids` map to: all that naming those photos needs. The
|
||||
/// library is passed over once, and only files of those stems are looked at closer, so a
|
||||
/// sidecar written for one photo does not cost a table of the whole library.
|
||||
pub(crate) fn among(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> StemOwners {
|
||||
let wanted: std::collections::HashSet<String> =
|
||||
ids.iter().filter_map(|id| cat.photo(*id)).filter_map(|p| file_path(p)).map(stem_key).collect();
|
||||
if wanted.is_empty() {
|
||||
return StemOwners(HashMap::new());
|
||||
}
|
||||
Self::where_wanted(cat, &wanted)
|
||||
}
|
||||
|
||||
/// The owners of the shared stems in `wanted`.
|
||||
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
let mut key = String::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
stem_key_into(&mut key, path);
|
||||
if !wanted.contains(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
(e.0, e.1) = (rank, p.id);
|
||||
}
|
||||
}
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
|
||||
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
|
||||
match (naming, file_path(p)) {
|
||||
@@ -514,33 +577,39 @@ impl StemOwners {
|
||||
}
|
||||
}
|
||||
|
||||
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
|
||||
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
|
||||
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (naming == SidecarNaming::Stem).then(|| StemOwners::among(cat, ids));
|
||||
ids.iter()
|
||||
.map(|id| match (cat.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, naming),
|
||||
_ => naming,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
|
||||
/// (per photo that is a pass over the whole catalog).
|
||||
/// [`Session::sidecar_naming`] for many photos, their stems' owners worked out once (per
|
||||
/// photo that is a pass over the whole catalog).
|
||||
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
|
||||
ids.iter()
|
||||
.map(|id| match (self.catalog.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
})
|
||||
.collect()
|
||||
namings_in(&self.catalog, self.xmp.naming, ids)
|
||||
}
|
||||
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::among(&self.catalog, &[id]).naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
|
||||
pub fn save_sidecar(&mut self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
|
||||
self.save_sidecar_with(id, &StemOwners::among(&self.catalog, &[id]))
|
||||
}
|
||||
|
||||
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
@@ -566,7 +635,12 @@ impl Session {
|
||||
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
|
||||
// Keep an existing packet's spelling/style so foreign properties survive this save and
|
||||
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
|
||||
let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred);
|
||||
// On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder
|
||||
// lists comes first, so the atomic write doesn't rename the packet.
|
||||
use crate::rename::MoveFs;
|
||||
let existing: Vec<PathBuf> = candidates.into_iter().filter(|path| path.is_file()).collect();
|
||||
let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned();
|
||||
let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred);
|
||||
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
@@ -637,7 +711,7 @@ impl Session {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let owners = StemOwners::of(&self.catalog);
|
||||
let owners = StemOwners::among(&self.catalog, &ids);
|
||||
for id in ids {
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
||||
&& let Err(e) = self.save_sidecar_with(id, &owners)
|
||||
@@ -759,3 +833,7 @@ mod tests_repair_companions;
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_repair_provenance.rs"]
|
||||
mod tests_repair_provenance;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_stem_owners.rs"]
|
||||
mod tests_stem_owners;
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Real Session partial moves, sidecar serialization/merging, AutoWrite and history.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use crate::tests_xmp::{same_path_for_volume, same_path_or_file};
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -16,7 +17,7 @@ impl MoveFs for Faults {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.0.iter().any(|p| p == a) {
|
||||
if self.0.iter().any(|p| same_path_or_file(p, a)) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
@@ -261,8 +262,8 @@ fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() {
|
||||
assert!(!second_packet.exists());
|
||||
let pending = queues(&s);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].from, shared);
|
||||
assert_eq!(pending[0].to, second_packet);
|
||||
assert!(same_path_for_volume(&pending[0].from, &shared), "{:?}", pending[0].from);
|
||||
assert!(same_path_for_volume(&pending[0].to, &second_packet), "{:?}", pending[0].to);
|
||||
let catalog_before = s.catalog.to_snapshot();
|
||||
let history_before = (s.undo.len(), s.redo.len(), s.commits());
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Native files and genuine Session entry points: packet ownership survives partial renames.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use crate::tests_xmp::same_path_or_file;
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -16,7 +17,7 @@ impl MoveFs for Faults {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.0.iter().any(|p| p == a) {
|
||||
if self.0.iter().any(|p| same_path_or_file(p, a)) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Real Session/serializer regressions for saves that overlap retained physical repairs.
|
||||
use super::*;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
use crate::tests_xmp::same_path_or_file;
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -19,14 +20,14 @@ impl MoveFs for PartialRename {
|
||||
RealFs.same_file(a, b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if a == self.sidecar || a == self.photo {
|
||||
if same_path_or_file(a, &self.sidecar) || same_path_or_file(a, &self.photo) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault"))
|
||||
} else {
|
||||
RealFs.rename_no_replace(a, b)
|
||||
}
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if a == self.sidecar {
|
||||
if same_path_or_file(a, &self.sidecar) {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault"))
|
||||
} else {
|
||||
RealFs.copy_no_replace(a, b)
|
||||
@@ -151,7 +152,9 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw
|
||||
assert_eq!(parsed.title.as_deref(), Some("Final title"));
|
||||
assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string()));
|
||||
if upper {
|
||||
assert!(!preferred.exists(), "merge uses the repaired uppercase packet");
|
||||
// by listing: on a case-insensitive volume `preferred` answers for the upper-case packet
|
||||
let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name());
|
||||
assert!(!listed, "merge uses the repaired uppercase packet");
|
||||
}
|
||||
for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] {
|
||||
s.execute(step, &json!({})).unwrap();
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
|
||||
//!
|
||||
//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
|
||||
//! that is synchronized, must not cost a table of them all.
|
||||
use super::*;
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, Source};
|
||||
|
||||
/// Paths that try the key: case in folders, names and endings, no ending, a dot file, endings
|
||||
/// inside folder names, non-ASCII letters (one that lower-cases to ASCII: the Kelvin sign).
|
||||
const PATHS: [&str; 26] = [
|
||||
"/pics/trip/IMG_1.CR3",
|
||||
"/pics/trip/IMG_1.JPG",
|
||||
"/pics/trip/img_1.jpeg",
|
||||
"/Pics/Trip/IMG_1.tif",
|
||||
"/pics/trip/IMG_2.JPG",
|
||||
"/pics/trip/IMG_3.CR3",
|
||||
"/pics/trip/IMG_3.cr3.JPG",
|
||||
"/pics/trip/IMG_3.cr3",
|
||||
"/pics/other/IMG_1.JPG",
|
||||
"/pics/other/IMG_1",
|
||||
"/pics/other/.hidden",
|
||||
"/pics/other/.hidden.jpg",
|
||||
"/pics/v1.2/a.b.JPG",
|
||||
"/pics/v1.2/a.b.cr3",
|
||||
"/pics/v1.2/a",
|
||||
"/pics/straße/Größe.JPG",
|
||||
"/pics/STRASSE/GRÖSSE.cr3",
|
||||
"/pics/straße/größe.CR3",
|
||||
"/pics/kelvin/\u{212a}1.JPG",
|
||||
"/pics/kelvin/k1.cr3",
|
||||
"/pics/ΟΔΟΣ/ΟΔΟΣ.JPG",
|
||||
"/pics/οδος/οδος.CR3",
|
||||
// (names that are all dots before their ending: the ending is no extension to replace)
|
||||
"/pics/dots/..cr3",
|
||||
"/pics/dots/..jpg",
|
||||
"/pics/dots/a./.",
|
||||
"/pics/dots/a.jpg",
|
||||
];
|
||||
|
||||
fn library() -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for path in PATHS {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01");
|
||||
if path.to_lowercase().ends_with("cr3") {
|
||||
p.kind = MediaKind::Raw;
|
||||
}
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
// a virtual copy of the first (it owns nothing) and a photo that is no file
|
||||
let copy = c.alloc_photo_id();
|
||||
let mut p = Photo::new(copy, Source::File { path: PATHS[0].to_string() }, "IMG_1.CR3", "X", 6, 4, "2026-01-01");
|
||||
p.copy_of = Some(ids[0]);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(copy);
|
||||
let demo = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(demo, Source::Demo { scene: 1 }, "demo", "X", 6, 4, "2026-01-01")) }).unwrap();
|
||||
ids.push(demo);
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
fn namings(owners: &StemOwners, c: &Catalog, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
ids.iter().map(|id| owners.naming(c.photo(*id).unwrap(), SidecarNaming::Stem)).collect()
|
||||
}
|
||||
|
||||
/// Given a library where files share stems in every way a name can, when the owners are worked
|
||||
/// out for some of its photos only, then each of those photos is named exactly as the table of
|
||||
/// the whole library names it: whichever photos are asked about, alone or together.
|
||||
#[test]
|
||||
fn owners_among_some_photos_name_them_as_the_whole_library_does() {
|
||||
let (c, ids) = library();
|
||||
let whole = StemOwners::of(&c);
|
||||
let expected = namings(&whole, &c, &ids);
|
||||
assert!(expected.contains(&SidecarNaming::Full) && expected.contains(&SidecarNaming::Stem), "both cases are there: {expected:?}");
|
||||
// all at once
|
||||
assert_eq!(namings(&StemOwners::among(&c, &ids), &c, &ids), expected);
|
||||
// one at a time, and every pair
|
||||
for (i, a) in ids.iter().enumerate() {
|
||||
assert_eq!(namings(&StemOwners::among(&c, &[*a]), &c, &[*a]), [expected[i]], "{:?}", PATHS.get(i));
|
||||
for (j, b) in ids.iter().enumerate() {
|
||||
assert_eq!(namings(&StemOwners::among(&c, &[*a, *b]), &c, &[*a, *b]), [expected[i], expected[j]]);
|
||||
}
|
||||
}
|
||||
// an id that is not in the library is passed over
|
||||
assert_eq!(StemOwners::among(&c, &[PhotoId(9_999)]).0.len(), 0);
|
||||
}
|
||||
|
||||
/// Given a library of many folders, when the owners are worked out for the photos of one, then
|
||||
/// the table holds that folder's shared stems and nothing of the others: what it costs follows
|
||||
/// the photos asked about, not the library.
|
||||
#[test]
|
||||
fn owners_among_some_photos_hold_nothing_of_the_rest() {
|
||||
let mut c = Catalog::new();
|
||||
let mut first = Vec::new();
|
||||
for folder in 0..200 {
|
||||
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "X", 6, 4, "2026-01-01");
|
||||
if name.ends_with("CR3") {
|
||||
p.kind = MediaKind::Raw;
|
||||
}
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
if folder == 0 {
|
||||
first.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(StemOwners::of(&c).0.len(), 200, "one shared stem a folder");
|
||||
let some = StemOwners::among(&c, &first);
|
||||
assert_eq!(some.0.len(), 1, "the one shared stem of the folder asked about");
|
||||
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
|
||||
}
|
||||
|
||||
/// The key written into a buffer (no allocation for a plain path) is the key: for every path
|
||||
/// above, for shapes a path from a file can have, and for every string of up to six of the
|
||||
/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
|
||||
/// not ASCII).
|
||||
#[test]
|
||||
fn the_key_in_a_buffer_is_the_key() {
|
||||
let odd = [
|
||||
"",
|
||||
"/",
|
||||
"a",
|
||||
"a.",
|
||||
".a",
|
||||
"..a",
|
||||
"...a",
|
||||
"a.b",
|
||||
"a/b.c/",
|
||||
"a/b.c//",
|
||||
"a/.b",
|
||||
"a/..b",
|
||||
"a/b.",
|
||||
"a/b..c",
|
||||
"a./.",
|
||||
"a\\b.c",
|
||||
"C:\\x\\y.JPG",
|
||||
"a/b.c/.",
|
||||
"a/b.c/..",
|
||||
"x.JPG ",
|
||||
"É.JPG",
|
||||
];
|
||||
let mut buf = String::new();
|
||||
for path in PATHS.iter().chain(&odd) {
|
||||
stem_key_into(&mut buf, path);
|
||||
assert_eq!(buf, stem_key(path), "{path:?}");
|
||||
}
|
||||
let letters = ['a', 'B', '.', '/', '\\', 'é'];
|
||||
let mut tried = 0usize;
|
||||
let mut todo: Vec<String> = vec![String::new()];
|
||||
while let Some(path) = todo.pop() {
|
||||
stem_key_into(&mut buf, &path);
|
||||
assert_eq!(buf, stem_key(&path), "{path:?}");
|
||||
tried += 1;
|
||||
if path.chars().count() < 6 {
|
||||
todo.extend(letters.iter().map(|c| format!("{path}{c}")));
|
||||
}
|
||||
}
|
||||
assert_eq!(tried, (0..=6).map(|n| 6usize.pow(n)).sum::<usize>());
|
||||
}
|
||||
+241
-15
@@ -55,6 +55,28 @@ const HEADER_MAX: u64 = 4096;
|
||||
/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve.
|
||||
const LOOK_FIELD: &str = "look_version";
|
||||
|
||||
/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's
|
||||
/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame
|
||||
/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong
|
||||
/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to
|
||||
/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size
|
||||
/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only
|
||||
/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test
|
||||
/// below; drop this to 95 for ~1.4× the size and roughly half the gain.)
|
||||
const JPEG_QUALITY: u8 = 97;
|
||||
|
||||
/// The header field that records which version of this module's own encoding wrote a proxy's pixels
|
||||
/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which
|
||||
/// tracks the camera-look fit that is baked into those pixels.
|
||||
const ENC_FIELD: &str = "enc";
|
||||
|
||||
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG
|
||||
/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which
|
||||
/// makes every proxy from before this version stale. Version 2 is the first to record the field at
|
||||
/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]).
|
||||
/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal.
|
||||
pub(crate) const ENCODE_VERSION: u64 = 2;
|
||||
|
||||
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
@@ -78,13 +100,19 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
|
||||
let rgba = Rgba8 { width: img.width, height: img.height, data };
|
||||
let jpg = lightcraft_codecs::encode_jpeg(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
|
||||
92,
|
||||
JPEG_QUALITY,
|
||||
lightcraft_codecs::ChromaSubsampling::S444,
|
||||
&Default::default(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = MAGIC.to_vec();
|
||||
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
|
||||
let mut head = serde_json::json!({
|
||||
"w": img.width,
|
||||
"h": img.height,
|
||||
"scale": scale,
|
||||
LOOK_FIELD: crate::camera_preview::LOOK_VERSION,
|
||||
ENC_FIELD: ENCODE_VERSION,
|
||||
});
|
||||
if let Some(t) = tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -265,10 +293,17 @@ fn header_look_version(head: &serde_json::Value) -> u64 {
|
||||
head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's
|
||||
/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a
|
||||
/// newer build is left alone (rebuilding it here would only undo that build's work), as is one
|
||||
/// that can't be read: those are [`is_valid`]'s business.
|
||||
/// The encode version a header records: a missing, negative, fractional or otherwise malformed
|
||||
/// value counts as 0 (older than any real version), like [`header_look_version`].
|
||||
fn header_encode_version(head: &serde_json::Value) -> u64 {
|
||||
head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the complete proxy at `path` is out of date for this build: written by an older
|
||||
/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are
|
||||
/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG
|
||||
/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that
|
||||
/// build's work), as is one that can't be read: those are [`is_valid`]'s business.
|
||||
pub fn is_stale(path: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
if !is_valid(path) {
|
||||
@@ -283,15 +318,18 @@ pub fn is_stale(path: &Path) -> bool {
|
||||
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
|
||||
rest.get(..nl)
|
||||
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
|
||||
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
|
||||
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION)
|
||||
}
|
||||
|
||||
/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked
|
||||
/// at ([`look_checked`]).
|
||||
/// The marker file in a smart previews folder: the camera-look fit version and encode version its
|
||||
/// proxies were last checked at ([`look_checked`]).
|
||||
const LOOK_MARKER: &str = "look-version";
|
||||
|
||||
/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's
|
||||
/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not.
|
||||
/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's
|
||||
/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts
|
||||
/// as not, and so does one written before the encode version was recorded — it only says the look
|
||||
/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`]
|
||||
/// finds.
|
||||
pub fn look_checked(dir: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false };
|
||||
@@ -299,14 +337,20 @@ pub fn look_checked(dir: &Path) -> bool {
|
||||
if f.take(64).read_to_string(&mut text).is_err() {
|
||||
return false;
|
||||
}
|
||||
text.trim().parse::<u64>().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION))
|
||||
let mut versions = text.split_whitespace();
|
||||
let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false };
|
||||
match (look.parse::<u64>(), enc.parse::<u64>()) {
|
||||
(Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the proxies in `dir` were checked at this build's camera-look fit version.
|
||||
/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode
|
||||
/// version.
|
||||
pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
|
||||
let path = dir.join(LOOK_MARKER);
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION);
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
@@ -451,4 +495,186 @@ mod tests {
|
||||
assert!(mean < 0.02, "mean encoded error {mean}");
|
||||
assert!(decode(b"nope").is_err());
|
||||
}
|
||||
|
||||
/// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into
|
||||
/// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never
|
||||
/// enters what is measured. Built from a rendered demo scene so the dimensions and the flat
|
||||
/// row-major layout are the real ones.
|
||||
fn smooth_scene(w: usize, h: usize) -> Rgb32f {
|
||||
let mut img = lightcraft_scenes::demo_library()[0].render(w, h);
|
||||
let (w, h) = (img.width.max(1), img.height.max(1));
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
let u = (i % w) as f32 / (w - 1).max(1) as f32;
|
||||
let v = (i / w) as f32 / (h - 1).max(1) as f32;
|
||||
let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step
|
||||
let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v];
|
||||
let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)];
|
||||
*px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0));
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The
|
||||
/// input is clamped into 0..1, the only range a proxy ever stores.
|
||||
fn lab(c: [f32; 3]) -> [f64; 3] {
|
||||
let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64);
|
||||
let (x, y, z) = (
|
||||
(0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047,
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b,
|
||||
(0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883,
|
||||
);
|
||||
let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 };
|
||||
let (fx, fy, fz) = (f(x), f(y), f(z));
|
||||
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
|
||||
}
|
||||
|
||||
/// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a
|
||||
/// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders.
|
||||
fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
|
||||
let n = a.data.len().min(b.data.len());
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let (p, q) = (lab(a.data[i]), lab(b.data[i]));
|
||||
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ n as f64
|
||||
}
|
||||
|
||||
/// A deliberately frozen copy of the pixel mapping and container this module used before the
|
||||
/// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the
|
||||
/// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on
|
||||
/// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the
|
||||
/// quality raise bought instead of trusting a written-down number.
|
||||
fn encode_at_q92(img: &Rgb32f) -> Vec<u8> {
|
||||
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
|
||||
let scale = if lum.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
let k = ((lum.len() as f64) * 0.9995) as usize;
|
||||
let k = k.min(lum.len() - 1);
|
||||
let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp);
|
||||
v.max(1.0)
|
||||
};
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let rgba = Rgba8 { width: img.width, height: img.height, data };
|
||||
let jpg = lightcraft_codecs::encode_jpeg(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
|
||||
92,
|
||||
lightcraft_codecs::ChromaSubsampling::S444,
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
// the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field
|
||||
let mut out = MAGIC.to_vec();
|
||||
let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
|
||||
out.extend_from_slice(head.to_string().as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(&jpg);
|
||||
out
|
||||
}
|
||||
|
||||
/// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one
|
||||
/// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its
|
||||
/// mean, and rebuilds the whole block from it, so a proxy differs from the original by a
|
||||
/// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels
|
||||
/// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share
|
||||
/// eightfold, which is what a per-pixel ΔE drowns in.
|
||||
fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
|
||||
let w = a.width.min(b.width).max(1);
|
||||
let h = a.height.min(b.height).max(1);
|
||||
let (mut sum, mut n) = (0.0f64, 0usize);
|
||||
for by in (0..h).step_by(8) {
|
||||
for bx in (0..w).step_by(8) {
|
||||
let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32);
|
||||
for y in by..(by + 8).min(h) {
|
||||
for x in bx..(bx + 8).min(w) {
|
||||
if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) {
|
||||
for k in 0..3 {
|
||||
sa[k] += f64::from(ca[k]);
|
||||
sb[k] += f64::from(cb[k]);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = f64::from(count.max(1));
|
||||
let (pa, pb) = (
|
||||
lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]),
|
||||
lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]),
|
||||
);
|
||||
sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
sum / n.max(1) as f64
|
||||
}
|
||||
|
||||
/// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit
|
||||
/// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene,
|
||||
/// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the
|
||||
/// quality raise is reverted and never trips on the honest result (measured 0.683).
|
||||
const PROXY_BLOCK_GAIN: f64 = 0.8;
|
||||
|
||||
#[test]
|
||||
fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() {
|
||||
let img = smooth_scene(256, 160);
|
||||
let ours = decode(&encode(&img, None).unwrap()).unwrap().0;
|
||||
let before = decode(&encode_at_q92(&img)).unwrap().0;
|
||||
let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before));
|
||||
let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before));
|
||||
println!(
|
||||
"smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \
|
||||
(per-pixel {:.3} vs {:.3})",
|
||||
block.0, block.1, pixel.0, pixel.1
|
||||
);
|
||||
assert!(block.1 > 0.0, "the old encoder must measure something to compare against");
|
||||
assert!(
|
||||
block.0 <= PROXY_BLOCK_GAIN * block.1,
|
||||
"q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \
|
||||
(the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)",
|
||||
block.0,
|
||||
PROXY_BLOCK_GAIN * block.1,
|
||||
block.1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_from_an_older_encode_is_stale() {
|
||||
let dir = temp("encode-version");
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let bytes = encode(&img, None).unwrap();
|
||||
let path = dir.join("p.lcsp");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
assert!(!is_stale(&path), "this build's own proxy is up to date");
|
||||
// one written before the encode version was recorded is not
|
||||
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
|
||||
let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
|
||||
assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION));
|
||||
let mut older = head.as_object().cloned().unwrap_or_default();
|
||||
older.remove(ENC_FIELD);
|
||||
let mut old = MAGIC.to_vec();
|
||||
old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes());
|
||||
old.extend_from_slice(&bytes[nl..]);
|
||||
std::fs::write(&path, &old).unwrap();
|
||||
assert!(is_valid(&path), "losing the field keeps the file readable");
|
||||
assert!(is_stale(&path), "an older encoding is rebuilt");
|
||||
// the marker records both versions, and one from before the encode version forces a scan
|
||||
assert!(!look_checked(&dir));
|
||||
mark_look_checked(&dir).unwrap();
|
||||
assert!(look_checked(&dir));
|
||||
std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap();
|
||||
assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,14 @@ impl SyncProgress {
|
||||
pub struct SyncInput {
|
||||
folder: String,
|
||||
scan: ScanInput,
|
||||
/// The library's photos in the folder, each with how its sidecar is named.
|
||||
photos: Vec<(Arc<Photo>, SidecarNaming)>,
|
||||
/// For the colour label names a sidecar may use.
|
||||
labels: Catalog,
|
||||
/// The library's photos in the folder.
|
||||
ids: Vec<PhotoId>,
|
||||
/// How the library names sidecars; how each photo's is named is worked out by the scan (a
|
||||
/// pass over the library, which the session's thread does not wait for).
|
||||
naming: SidecarNaming,
|
||||
/// The library as it is now (the one the scan looks files up in): for those photos, their
|
||||
/// sidecars' names and the colour label names a sidecar may use.
|
||||
labels: Arc<Catalog>,
|
||||
}
|
||||
|
||||
/// What changed in a folder since it came into the library.
|
||||
@@ -176,10 +180,9 @@ impl SyncInput {
|
||||
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
|
||||
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
|
||||
}
|
||||
let namings = s.sidecar_namings(&ids);
|
||||
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
|
||||
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
|
||||
let labels = scan.library();
|
||||
Ok(SyncInput { folder: path.to_string(), scan, ids, naming: s.xmp.naming, labels })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,11 +193,16 @@ impl SyncInput {
|
||||
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
|
||||
/// is set.
|
||||
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let SyncInput { folder, scan, photos, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
|
||||
let SyncInput { folder, scan, ids, naming, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
|
||||
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
|
||||
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
|
||||
}
|
||||
// (how each photo's sidecar is named: a pass over the library, made here and not while the
|
||||
// session waits, and not at all for a folder that is not there)
|
||||
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
|
||||
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
|
||||
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
|
||||
@@ -402,6 +402,48 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() {
|
||||
assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's
|
||||
/// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a
|
||||
/// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear
|
||||
/// neighbour past isize::MAX). Such a photo renders, quickly.
|
||||
#[test]
|
||||
fn degenerate_crops_render_with_spots_and_masks() {
|
||||
for rect in [[0.5, 0.5, 0.5, 0.5], [0.0, 0.0, 1e-9, 1.0]] {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("spot.add", &json!({"points": [[0.5, 0.5]], "size": 0.2, "mode": "heal"})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "radial", "rx": 0.3, "ry": 0.3})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
|
||||
s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.render_now(id, 700, 450).unwrap();
|
||||
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
|
||||
assert!(t.elapsed().as_secs_f64() < 60.0, "{rect:?}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
|
||||
/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
|
||||
#[test]
|
||||
fn mask_move_with_extreme_deltas() {
|
||||
let mut s = demo();
|
||||
for kind in ["linear", "radial", "brush"] {
|
||||
s.execute("mask.add", &json!({"kind": kind})).unwrap();
|
||||
}
|
||||
let ids = |s: &Session| active_dev(s).masks.iter().map(|m| m.id).collect::<Vec<_>>();
|
||||
let [a, b, c] = ids(&s)[..] else { panic!("{:?}", ids(&s)) };
|
||||
s.execute("mask.move", &json!({"id": b, "delta": i64::MAX})).unwrap();
|
||||
assert_eq!(ids(&s), [a, c, b], "to the bottom");
|
||||
s.execute("mask.move", &json!({"id": b, "delta": i64::MIN})).unwrap();
|
||||
assert_eq!(ids(&s), [b, a, c], "to the top");
|
||||
s.execute("mask.move", &json!({"id": c, "to": i64::MIN})).unwrap();
|
||||
assert_eq!(ids(&s), [c, b, a]);
|
||||
s.execute("mask.move", &json!({"id": c, "to": i64::MAX})).unwrap();
|
||||
assert_eq!(ids(&s), [b, a, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_reorder_and_duplicate_and_invert() {
|
||||
let mut s = demo();
|
||||
@@ -1178,3 +1220,269 @@ fn hdr_edits_render_with_their_hdr_histogram() {
|
||||
assert!(r.histogram.above_sdr() > 0.0);
|
||||
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
|
||||
}
|
||||
|
||||
/// Found by fuzzing catalog snapshots: an album filed under one id but naming its own folder's
|
||||
/// listed itself inside itself, and `albums.list` recursed until the stack overflowed (an abort:
|
||||
/// no guard catches it). Loading repairs such a catalog (`Catalog::repair`); one that skipped
|
||||
/// that still lists, the loop cut off.
|
||||
#[test]
|
||||
fn albums_list_survives_a_catalog_that_loops() {
|
||||
let mut s = Session::with_demo();
|
||||
let f = s.execute("album.create", &json!({"name": "F", "folder": true})).unwrap()["id"].as_u64().unwrap();
|
||||
let a = s.execute("album.create", &json!({"name": "A", "parent": f})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&s.catalog.to_snapshot()).unwrap();
|
||||
v["albums"][a.to_string()]["id"] = f.into();
|
||||
// as read, without the repair a load does
|
||||
s.catalog = serde_json::from_value(v).unwrap();
|
||||
let r = s.execute("albums.list", &json!({})).unwrap();
|
||||
assert!(r.to_string().contains("\"F\""), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_paste_validates_groups_and_rejects_malformed_with_zero_mutations() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
// Photo A has exposure = 1.0, vibrance = 30.0
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
|
||||
s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
|
||||
|
||||
// 1. Invalid group names are rejected
|
||||
for invalid_group in ["bogusGroup", "LIGHT", "Exposure", "unknown"] {
|
||||
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": [invalid_group]})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 2. Incorrect JSON types are rejected
|
||||
for invalid_param in [
|
||||
json!("light"), // string instead of sequence
|
||||
json!(42), // number
|
||||
json!(true), // boolean
|
||||
json!({"name": "light"}), // object
|
||||
json!([123]), // array of non-string
|
||||
json!(["light", 456]), // mixed array with invalid type
|
||||
json!([true]), // array of bool
|
||||
] {
|
||||
let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": invalid_param})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 3. Valid groups remain supported: pasting only "light" does not paste "color"
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": ["light"]})).unwrap();
|
||||
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_light.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
|
||||
|
||||
// 4. Omitted groups preserves default behavior (all copied groups pasted)
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_omitted.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
|
||||
// 5. Explicit null behaves as omitted
|
||||
s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": null})).unwrap();
|
||||
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_null.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_null.color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_sync_validates_groups_and_rejects_malformed_with_zero_mutations() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
// Setup: Photo A has exposure = 1.0, vibrance = 30.0
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
|
||||
|
||||
// Select Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
|
||||
|
||||
// 1. Invalid group names are rejected
|
||||
for invalid_group in ["bogusGroup", "LIGHT", "WhiteBalance", "invalid_name"] {
|
||||
let err = s.execute("develop.sync", &json!({"groups": [invalid_group]})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 2. Incorrect JSON types are rejected
|
||||
for invalid_param in [
|
||||
json!("light"), // string instead of sequence
|
||||
json!(100), // number
|
||||
json!(false), // boolean
|
||||
json!({"groups": "light"}), // object
|
||||
json!([999]), // array of non-string
|
||||
json!(["color", false]), // mixed array with invalid type
|
||||
json!([null]), // array with null
|
||||
] {
|
||||
let err = s.execute("develop.sync", &json!({"groups": invalid_param})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
|
||||
}
|
||||
|
||||
// 3. Valid groups remain supported: syncing only "light" does not sync "color"
|
||||
s.execute("develop.sync", &json!({"groups": ["light"]})).unwrap();
|
||||
let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_light.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
|
||||
|
||||
// Reselect Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
// 4. Omitted groups preserves default behavior (all default copy groups synced)
|
||||
s.execute("develop.sync", &json!({})).unwrap();
|
||||
let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_omitted.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_omitted.color.vibrance, 30.0);
|
||||
|
||||
// Reset photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
|
||||
// Reselect Photo A (active) and Photo B
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
|
||||
|
||||
// 5. Explicit null behaves as omitted
|
||||
s.execute("develop.sync", &json!({"groups": null})).unwrap();
|
||||
let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
assert_eq!(dev_after_null.light.exposure, 1.0);
|
||||
assert_eq!(dev_after_null.color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_copy_and_paste_previous_validate_groups() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let photo_a = ids[0];
|
||||
let photo_b = ids[1];
|
||||
|
||||
s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.5}})).unwrap();
|
||||
|
||||
// develop.copy rejects invalid group
|
||||
let copy_err = s.execute("develop.copy", &json!({"groups": ["notAGroup"]})).unwrap_err();
|
||||
assert!(matches!(copy_err, crate::EngineError::BadParams { .. }));
|
||||
|
||||
// develop.copy accepts valid groups
|
||||
s.execute("develop.copy", &json!({"groups": ["light"]})).unwrap();
|
||||
|
||||
// Switch active photo to photo_b so photo_a becomes previous_active
|
||||
s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
|
||||
|
||||
let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
|
||||
|
||||
// develop.pastePrevious rejects invalid group
|
||||
let prev_err = s.execute("develop.pastePrevious", &json!({"groups": ["badGroup"]})).unwrap_err();
|
||||
assert!(matches!(prev_err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
|
||||
|
||||
// develop.pastePrevious accepts valid group
|
||||
s.execute("develop.pastePrevious", &json!({"groups": ["light"]})).unwrap();
|
||||
assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_validate_groups_on_create_and_update() {
|
||||
let mut s = demo();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 25}})).unwrap();
|
||||
let initial_preset_count = s.presets.len();
|
||||
|
||||
// 1. preset.create rejects invalid group names and incorrect JSON types without creating a preset
|
||||
for invalid_groups in [json!(["bogusGroup"]), json!("light"), json!(123), json!([true]), json!(["light", 99])] {
|
||||
let err = s.execute("preset.create", &json!({"name": "Test", "groups": invalid_groups})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
assert_eq!(s.presets.len(), initial_preset_count);
|
||||
}
|
||||
|
||||
// 2. preset.create with valid groups creates a preset with only those groups
|
||||
let res = s.execute("preset.create", &json!({"name": "ValidPreset", "groups": ["light"]})).unwrap();
|
||||
let pid = res["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(s.presets.len(), initial_preset_count + 1);
|
||||
let p = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert!(p.settings.get("light").is_some());
|
||||
assert!(p.settings.get("color").is_none());
|
||||
|
||||
// 3. preset.create with omitted groups uses default copy groups
|
||||
let res_omitted = s.execute("preset.create", &json!({"name": "DefaultPreset"})).unwrap();
|
||||
let pid_omitted = res_omitted["id"].as_str().unwrap().to_string();
|
||||
let p_omitted = s.presets.iter().find(|p| p.id == pid_omitted).unwrap();
|
||||
assert!(p_omitted.settings.get("light").is_some());
|
||||
assert!(p_omitted.settings.get("color").is_some());
|
||||
|
||||
// Change develop settings
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 2.0, "color.vibrance": 50}})).unwrap();
|
||||
|
||||
let preset_before_update = s.presets.iter().find(|p| p.id == pid).unwrap().clone();
|
||||
|
||||
// 4. preset.update rejects invalid group names and incorrect JSON types without modifying the preset
|
||||
for invalid_groups in [json!(["bogusGroup"]), json!("color"), json!(999), json!([false]), json!(["color", "bad"])] {
|
||||
let err = s.execute("preset.update", &json!({"id": pid, "groups": invalid_groups})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }));
|
||||
let current = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(*current, preset_before_update);
|
||||
}
|
||||
|
||||
// 5. preset.update with valid groups updates specified groups
|
||||
s.execute("preset.update", &json!({"id": pid, "groups": ["light", "color"]})).unwrap();
|
||||
let p_after_valid = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(p_after_valid.settings["light"]["exposure"], 2.0);
|
||||
assert_eq!(p_after_valid.settings["color"]["vibrance"], 50.0);
|
||||
|
||||
// 6. preset.update with omitted groups updates existing preset keys
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": -1.0, "color.vibrance": -20}})).unwrap();
|
||||
s.execute("preset.update", &json!({"id": pid})).unwrap();
|
||||
let p_after_omitted = s.presets.iter().find(|p| p.id == pid).unwrap();
|
||||
assert_eq!(p_after_omitted.settings["light"]["exposure"], -1.0);
|
||||
assert_eq!(p_after_omitted.settings["color"]["vibrance"], -20.0);
|
||||
}
|
||||
|
||||
/// A Heal or Remove spot whose size (from a sidecar, which isn't held to the slider's range) is far past the photo
|
||||
/// made the render's radius infinite: its heal blur and the source it reads went with it, overflowing the blur's box
|
||||
/// radii and the bilinear sampler (issue #1519). Such spots render, quickly, as a spot over the whole frame.
|
||||
#[test]
|
||||
fn oversized_spots_render() {
|
||||
use lightcraft_develop::{Spot, SpotMode};
|
||||
for size in [10.0, 1e30, f64::MAX, f64::INFINITY, f64::NAN] {
|
||||
for mode in [SpotMode::Heal, SpotMode::Remove, SpotMode::Clone] {
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
dd.spots.push(Spot { mode, points: vec![lightcraft_geom::Point::new(0.5, 0.5)], size, ..Default::default() });
|
||||
s.set_develop(id, dd, "Remove").unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.render_now(id, 300, 200).unwrap();
|
||||
assert!(r.image.width >= 1 && r.image.height >= 1, "{size} {mode:?}");
|
||||
assert!(t.elapsed().as_secs_f64() < 60.0, "{size} {mode:?}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,11 @@ fn quick_develop_reset_and_batch_paths_fit_too() {
|
||||
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
|
||||
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
|
||||
|
||||
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
|
||||
// back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388)
|
||||
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
|
||||
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
|
||||
assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0));
|
||||
assert!(g.is_within_image(w, h));
|
||||
}
|
||||
|
||||
@@ -106,3 +106,21 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(close(geometry(&s), start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_shrunk_by_straightening_grows_back_towards_0() {
|
||||
// issue #1388: slide the Straighten slider out and back, in one drag or in several
|
||||
let mut s = Session::with_demo();
|
||||
let (w, h) = dims(&s);
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
|
||||
for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] {
|
||||
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}");
|
||||
}
|
||||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||
s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap();
|
||||
s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap();
|
||||
let g = geometry(&s);
|
||||
assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}");
|
||||
}
|
||||
@@ -277,6 +277,29 @@ fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() {
|
||||
let _ = std::fs::remove_dir_all(&x.dir);
|
||||
}
|
||||
|
||||
/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then
|
||||
/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described.
|
||||
#[test]
|
||||
fn inspecting_another_apps_model_folder_says_what_is_missing() {
|
||||
let dir = temp_dir("denoise-foreign");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
|
||||
let src = dir.join("rawdenoise");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let onnx = src.join("rawdenoise.onnx");
|
||||
std::fs::write(&onnx, b"not an onnx file").unwrap();
|
||||
let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
|
||||
let e = inspect();
|
||||
assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}");
|
||||
std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap();
|
||||
let e = inspect();
|
||||
assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}");
|
||||
std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap();
|
||||
let e = inspect();
|
||||
assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() {
|
||||
let mut x = setup("background", true);
|
||||
|
||||
@@ -455,6 +455,21 @@ fn hdr_avif_export_is_pq_rec2020() {
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_avif_export_hlg() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70}));
|
||||
assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg);
|
||||
assert_eq!(o.to_json()["hdrTransfer"], "hlg");
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
// nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box
|
||||
let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box");
|
||||
assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]);
|
||||
assert!(!hdr.bytes.windows(4).any(|w| w == b"clli"));
|
||||
}
|
||||
|
||||
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
|
||||
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
|
||||
#[test]
|
||||
|
||||
@@ -205,10 +205,24 @@ fn detect_without_the_model_says_where_to_get_it() {
|
||||
let d = temp("nodetector");
|
||||
let mut s = Session::with_demo();
|
||||
// no folder for models at all, then a folder that has none yet
|
||||
assert!(s.execute("faces.detect", &json!({})).is_err());
|
||||
let e = s.execute("faces.detect", &json!({})).unwrap_err();
|
||||
assert_ne!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "nothing to download into: {e}");
|
||||
s.face_models_dir = Some(d.join("models"));
|
||||
let e = s.execute("faces.detect", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("Settings > Faces"), "{e}");
|
||||
let e = s.execute("faces.detect", &json!({})).unwrap_err();
|
||||
// the app offers the download on the kind (#1046), the person reads the message
|
||||
assert_eq!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "{e}");
|
||||
assert!(
|
||||
e.to_string().starts_with("invalid parameters for `faces.detect`: ") && e.to_string().contains("Settings > Faces"),
|
||||
"the `error` text clients see is unchanged: {e}"
|
||||
);
|
||||
// a detector that is there but unusable also points to Settings > Faces, and is not a missing one
|
||||
let home = d.join("models").join(lightcraft_faces::known::YUNET_ID);
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("model.onnx"), b"not a model").unwrap();
|
||||
std::fs::write(home.join("face-model.json"), b"{}").unwrap();
|
||||
let e = s.execute("faces.detect", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("Settings > Faces"), "{e}");
|
||||
assert_eq!(e.kind(), crate::ErrorKind::BadParams, "{e}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Face tiles from the embedded preview, kept in the thumbnail cache (issue #1440).
|
||||
//!
|
||||
//! The fixture is a TIFF-family raw with two embedded JPEG previews (a small flat red one and a
|
||||
//! larger picture) and a sensor strip, as #1020's planned-read tests build them. Counting wrappers
|
||||
//! around the session's file loader (the path that reads the whole original and develops the raw)
|
||||
//! and its preview loader say which source a face tile came from.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
use lightcraft_geom::Rect;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const W: u32 = 600;
|
||||
const H: u32 = 400;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-face-tiles-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The big preview's pixel at (x, y): smooth ramps and a soft disc, so a resampled square of it
|
||||
/// compares well between two resamplers.
|
||||
fn big_pixel(x: u32, y: u32) -> [u8; 3] {
|
||||
let (dx, dy) = (x as f64 - 300.0, y as f64 - 200.0);
|
||||
let disc = (-(dx * dx + dy * dy) / (2.0 * 40.0 * 40.0)).exp();
|
||||
[(40.0 + 150.0 * x as f64 / W as f64) as u8, (60.0 + 120.0 * y as f64 / H as f64) as u8, (50.0 + 160.0 * disc) as u8]
|
||||
}
|
||||
|
||||
fn jpeg(w: u32, h: u32, px: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
|
||||
let rgb: Vec<u8> = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| px(x, y)).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
encode_jpeg(&img, 95, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap()
|
||||
}
|
||||
|
||||
/// A raw (DNG-tagged) whose IFD0 is a small flat red preview, IFD1 the big picture and IFD2 a CFA
|
||||
/// "sensor" strip of `tail` bytes. The sensor data can't be developed, so the import describes the
|
||||
/// photo by its largest preview (600 × 400) and the develop path renders from it, after reading
|
||||
/// the whole file.
|
||||
fn raw_with_two_previews(tail: usize) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let preview = |w: u32, h: u32, bytes: Vec<u8>, first: bool| {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
if first {
|
||||
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||||
}
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![bytes] });
|
||||
ifd
|
||||
};
|
||||
let small = preview(120, 80, jpeg(120, 80, |_, _| [200, 40, 40]), true);
|
||||
let big = preview(W, H, jpeg(W, H, big_pixel), false);
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
|
||||
TiffWriter::default().write(&[small, big, raw]).unwrap()
|
||||
}
|
||||
|
||||
/// How often each loader ran.
|
||||
#[derive(Clone, Default)]
|
||||
struct Loads {
|
||||
/// The original read whole and developed (for a raw: its sensor data decoded).
|
||||
files: Arc<AtomicUsize>,
|
||||
/// The embedded preview read (planned read: header and preview only).
|
||||
previews: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Loads {
|
||||
fn get(&self) -> (usize, usize) {
|
||||
(self.files.load(Ordering::SeqCst), self.previews.load(Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the session's loads from now on.
|
||||
fn count_loads(s: &mut Session) -> Loads {
|
||||
let loads = Loads::default();
|
||||
let file = s.media.file_loader.clone().unwrap();
|
||||
let n = loads.files.clone();
|
||||
s.media.file_loader = Some(Arc::new(move |p: &str, e: usize| {
|
||||
n.fetch_add(1, Ordering::SeqCst);
|
||||
file(p, e)
|
||||
}));
|
||||
let preview = s.media.preview_loader.clone().unwrap();
|
||||
let n = loads.previews.clone();
|
||||
s.media.preview_loader = Some(Arc::new(move |p: &str, e: usize| {
|
||||
n.fetch_add(1, Ordering::SeqCst);
|
||||
preview(p, e)
|
||||
}));
|
||||
loads
|
||||
}
|
||||
|
||||
/// A session on the library in `dir` (created on first use).
|
||||
fn open(dir: &Path) -> Session {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(dir.join("library"), false).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A library holding the fixture: (dir, session, the photo, the file).
|
||||
fn setup(tag: &str) -> (PathBuf, Session, PhotoId, PathBuf) {
|
||||
let dir = temp_dir(tag);
|
||||
let file = dir.join("faces.dng");
|
||||
std::fs::write(&file, raw_with_two_previews(400_000)).unwrap();
|
||||
let mut s = open(&dir);
|
||||
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let p = s.catalog.photos().next().unwrap();
|
||||
assert_eq!((p.kind, p.width, p.height), (lightcraft_catalog::MediaKind::Raw, W, H), "described by its largest preview");
|
||||
let id = p.id;
|
||||
(dir, s, id, file)
|
||||
}
|
||||
|
||||
/// A face of a tenth of the frame, centred: a 64 px tile needs a 377 px source, which the 600 px
|
||||
/// preview has.
|
||||
const FACE: Rect = Rect { x0: 0.45, y0: 0.45, x1: 0.55, y1: 0.55 };
|
||||
/// A face of 2 %: a 64 px tile would need a 1883 px source; the preview is under ¾ of that.
|
||||
const TINY_FACE: Rect = Rect { x0: 0.49, y0: 0.49, x1: 0.51, y1: 0.51 };
|
||||
|
||||
fn tile(s: &mut Session, id: PhotoId, face: Rect, edge: usize) -> Result<Rgba8, String> {
|
||||
s.face_job(id, face, edge).unwrap().run().rendered.map(|r| r.image)
|
||||
}
|
||||
|
||||
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let sum: u64 = a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| u64::from(p[c].abs_diff(q[c])))).sum();
|
||||
sum as f64 / (a.data.len() * 3) as f64
|
||||
}
|
||||
|
||||
/// An unedited raw's face tile is cut from its largest embedded preview: the original is never read
|
||||
/// whole nor its raw developed, and the tile matches the develop path's at tile size.
|
||||
#[test]
|
||||
fn an_unedited_raws_face_comes_from_its_largest_preview() {
|
||||
let (dir, mut s, id, file) = setup("preview");
|
||||
// (the preview loader's planned read: the header and the preview, not the sensor strip)
|
||||
let (_, _, read) = crate::files::preview_window(&file.to_string_lossy(), usize::MAX).unwrap().unwrap();
|
||||
assert!(read.fraction() < 0.5, "{read:?}");
|
||||
let loads = count_loads(&mut s);
|
||||
let from_preview = tile(&mut s, id, FACE, 64).unwrap();
|
||||
assert_eq!((from_preview.width, from_preview.height), (64, 64));
|
||||
assert_eq!(loads.get(), (0, 1), "one preview read, no whole-file read or raw decode");
|
||||
// the big preview, not the small flat red one
|
||||
let centre = from_preview.data[32 * 64 + 32];
|
||||
assert!(centre[2] > 150 && centre[0] < 150, "the face's centre is the big preview's disc: {centre:?}");
|
||||
|
||||
// the develop path (no preview loader: what every face used before), from the memory cache's reach
|
||||
s.media.preview_loader = None;
|
||||
let mut job = s.face_job(id, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
let developed = job.run().rendered.unwrap().image;
|
||||
assert_eq!(loads.files.load(Ordering::SeqCst), 1, "the develop path reads the original");
|
||||
let d = mean_abs_diff(&from_preview, &developed);
|
||||
eprintln!("mean difference preview/develop: {d:.3}");
|
||||
assert!(d < 4.0, "the preview's tile looks like the developed one: mean difference {d:.2} of 255");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A preview under the soft limit (¾ of the pixels the tile needs across the face) leaves the tile to
|
||||
/// the develop path; so does an edited photo, whose look the camera's JPEG doesn't have.
|
||||
#[test]
|
||||
fn a_preview_too_small_or_an_edited_photo_falls_back_to_the_develop_path() {
|
||||
let (dir, mut s, id, _) = setup("fallback");
|
||||
let loads = count_loads(&mut s);
|
||||
let img = tile(&mut s, id, TINY_FACE, 64).unwrap();
|
||||
assert_eq!((img.width, img.height), (64, 64));
|
||||
assert_eq!(loads.get(), (1, 1), "the preview was tried, then the original developed");
|
||||
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let edited = tile(&mut s, id, FACE, 64).unwrap();
|
||||
assert_eq!(loads.get(), (2, 1), "an edited photo's face is developed; its preview isn't read");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
drop(edited);
|
||||
}
|
||||
|
||||
/// Rendered tiles are kept in the library's thumbnail cache: a new session (the next launch) shows
|
||||
/// them without reading the original, from the preview or the develop path alike; and a size
|
||||
/// between two steps reuses the tile of its step.
|
||||
#[test]
|
||||
fn face_tiles_are_kept_on_disk_for_the_next_launch() {
|
||||
let (dir, mut s, id, _) = setup("disk");
|
||||
let first = tile(&mut s, id, FACE, 64).unwrap();
|
||||
let _ = tile(&mut s, id, TINY_FACE, 64).unwrap();
|
||||
drop(s);
|
||||
|
||||
let mut s = open(&dir);
|
||||
let loads = count_loads(&mut s);
|
||||
let again = tile(&mut s, id, FACE, 64).unwrap();
|
||||
let tiny = tile(&mut s, id, TINY_FACE, 64).unwrap();
|
||||
assert_eq!(loads.get(), (0, 0), "both tiles come from the disk cache: the original is not touched");
|
||||
assert_eq!((again.width, tiny.width), (64, 64));
|
||||
assert!(mean_abs_diff(&first, &again) < 3.0, "the cached tile is the rendered one (as a JPEG)");
|
||||
// 60 px asks for the 64 px step: the same tile, no new render
|
||||
assert_eq!(s.face_job(id, FACE, 60).unwrap().key, s.face_job(id, FACE, 64).unwrap().key);
|
||||
let _ = tile(&mut s, id, FACE, 60).unwrap();
|
||||
assert_eq!(loads.get(), (0, 0));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// What can go wrong never panics: a preview loader that fails or answers nonsense, a damaged
|
||||
/// preview, face boxes at the edge or degenerate, an offline original, a damaged cache entry.
|
||||
#[test]
|
||||
fn face_tiles_never_crash() {
|
||||
let (dir, mut s, id, file) = setup("crash");
|
||||
|
||||
// a damaged cache entry: dropped, and the tile rendered again (from the preview)
|
||||
let job = s.face_job(id, FACE, 64).unwrap();
|
||||
let key = job.cache.as_ref().unwrap().1.to_string();
|
||||
assert!(job.run().rendered.is_ok());
|
||||
let thumbs = s.library.as_ref().unwrap().thumbs_dir();
|
||||
drop(s);
|
||||
let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
|
||||
assert!(entry.exists(), "the tile was written to the thumbnail cache");
|
||||
std::fs::write(&entry, b"not a jpeg").unwrap();
|
||||
let mut s = open(&dir);
|
||||
let loads = count_loads(&mut s);
|
||||
assert_eq!(tile(&mut s, id, FACE, 64).unwrap().width, 64);
|
||||
assert_eq!(loads.get(), (0, 1), "rendered again from the preview");
|
||||
|
||||
// face boxes at the corners, oversized, empty, not numbers: a tile or an error, never a panic
|
||||
for face in [
|
||||
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
|
||||
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
|
||||
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
|
||||
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
|
||||
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
|
||||
Rect { x0: f64::NEG_INFINITY, y0: f64::NAN, x1: f64::NAN, y1: f64::NAN },
|
||||
] {
|
||||
for edge in [0, 1, 64, usize::MAX] {
|
||||
let img = tile(&mut s, id, face, edge).unwrap_or_else(|e| panic!("{face:?} at {edge}: {e}"));
|
||||
assert_eq!(img.width, img.height, "{face:?} at {edge}");
|
||||
}
|
||||
}
|
||||
|
||||
// a preview loader that answers nonsense or nothing: the develop path renders the tile
|
||||
let id2 = id;
|
||||
for answer in [None, Some(Rgba8 { width: W as usize, height: H as usize, data: Vec::new() }), Some(Rgba8::new(1, 1))] {
|
||||
s.media.preview_loader = Some(Arc::new(move |_: &str, _: usize| answer.clone()));
|
||||
let mut job = s.face_job(id2, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
assert_eq!(job.run().rendered.unwrap().image.width, 64);
|
||||
}
|
||||
|
||||
// a damaged preview in the file: no tile from it, and no panic either way
|
||||
s.media.preview_loader = Some(crate::files::fs_preview_loader());
|
||||
let mut bytes = std::fs::read(&file).unwrap();
|
||||
let n = bytes.len();
|
||||
for b in bytes.iter_mut().take(n - 400_000).skip(1000) {
|
||||
*b = 0x5a;
|
||||
}
|
||||
std::fs::write(&file, &bytes).unwrap();
|
||||
let mut job = s.face_job(id, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
let _ = job.run();
|
||||
|
||||
// the original gone (an offline drive): an error, no panic
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
let mut job = s.face_job(id, FACE, 64).unwrap();
|
||||
job.cache = None;
|
||||
assert!(job.run().rendered.is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -31,11 +31,21 @@ fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool
|
||||
})
|
||||
}
|
||||
|
||||
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
|
||||
fn slash(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
}
|
||||
|
||||
fn file_source(s: &Session, id: u64) -> String {
|
||||
let Source::File { path } = &s.catalog.photo(PhotoId(id)).unwrap().source else { panic!("synthetic file fixture") };
|
||||
slash(path)
|
||||
}
|
||||
|
||||
fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) {
|
||||
let files = fs.listing();
|
||||
for photo in s.catalog.photos() {
|
||||
let Source::File { path } = &photo.source else { panic!("synthetic file fixture") };
|
||||
assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0);
|
||||
assert!(files.iter().any(|(file, _)| *file == slash(path)), "photo {} points to missing {path}: {files:?}", photo.id.0);
|
||||
assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str());
|
||||
}
|
||||
let replayed = memory_library(store);
|
||||
@@ -102,7 +112,8 @@ fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal()
|
||||
let renamed = fs.listing();
|
||||
s.merge_undo(2, "Rename Twice");
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]);
|
||||
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
|
||||
assert_eq!(moves, vec![("/p/final.jpg".to_string(), "/p/a.jpg".to_string())]);
|
||||
// First exercise the ordinary merged step, before any corrective paths exist.
|
||||
for redo in [false, true] {
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
@@ -274,7 +285,7 @@ fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollb
|
||||
file_history_step(&mut s, &fs, redo).unwrap();
|
||||
let mut after = before
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone()))
|
||||
.map(|(path, bytes)| (path.replace(slash(&from).trim_end_matches(".jpg"), slash(&to).trim_end_matches(".jpg")), bytes.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
after.sort();
|
||||
assert_eq!(fs.listing(), after);
|
||||
@@ -343,7 +354,7 @@ fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() {
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1);
|
||||
*fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()];
|
||||
assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err());
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() });
|
||||
assert_eq!(file_source(&s, 1), "/p/Trip.jpg");
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2);
|
||||
assert_file_catalog_and_journal(&s, &fs, &store);
|
||||
@@ -513,10 +524,11 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
|
||||
);
|
||||
s.merge_undo(3, "Rename Three Times");
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
|
||||
assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]);
|
||||
let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect();
|
||||
assert_eq!(moves, vec![("/p/d.jpg".to_string(), "/p/b.jpg".to_string()), ("/p/c.jpg".to_string(), "/p/a.jpg".to_string())]);
|
||||
// The second photo move sticks; the preceding d -> b move must roll back to d.
|
||||
*fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()];
|
||||
let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string();
|
||||
let error = slash(&file_history_step(&mut s, &fs, false).unwrap_err().to_string());
|
||||
assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}");
|
||||
assert!(error.contains("/p/c.jpg could not be moved back"), "{error}");
|
||||
let files = fs.listing();
|
||||
@@ -529,9 +541,8 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() {
|
||||
]
|
||||
);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (1, 0));
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() });
|
||||
let photo2 = s.catalog.photo(PhotoId(2)).unwrap();
|
||||
assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg");
|
||||
let Source::File { path } = &photo2.source else { panic!("file fixture") };
|
||||
assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
|
||||
assert_eq!(file_source(&s, 1), "/p/a.jpg");
|
||||
let path = file_source(&s, 2);
|
||||
assert_eq!(path, "/p/d.jpg", "photo 2 rolled back physically and must still identify d.jpg");
|
||||
assert_eq!(files.iter().find(|(file, _)| *file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2"));
|
||||
}
|
||||
@@ -40,7 +40,8 @@ impl Scratch {
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
// the host's separator throughout, as the code under test spells joined paths
|
||||
self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a
|
||||
//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn exposure(s: &Session) -> f64 {
|
||||
s.develop_of(s.active().unwrap()).unwrap().light.exposure
|
||||
}
|
||||
|
||||
fn list(s: &mut Session) -> (usize, Option<usize>) {
|
||||
let h = s.execute("history.list", &json!({})).unwrap();
|
||||
(h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize))
|
||||
}
|
||||
|
||||
/// A demo session with three exposure edits (1, 2, 3) as its latest History steps.
|
||||
fn three_edits() -> (Session, usize) {
|
||||
let mut s = Session::with_demo();
|
||||
for v in [1.0, 2.0, 3.0] {
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
|
||||
}
|
||||
let (n, cursor) = list(&mut s);
|
||||
assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step");
|
||||
(s, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_back_adds_no_step_and_forward_returns() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
|
||||
assert_eq!(exposure(&s), 1.0);
|
||||
assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept");
|
||||
s.execute("history.next", &json!({})).unwrap();
|
||||
assert_eq!(exposure(&s), 2.0);
|
||||
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
assert_eq!(list(&mut s), (n, Some(n - 1)));
|
||||
assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step");
|
||||
s.execute("history.previous", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2))));
|
||||
assert!(s.execute("history.restore", &json!({"index": n})).is_err());
|
||||
assert!(s.execute("history.restore", &json!({"index": -1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_edit_behind_the_end_drops_the_later_steps() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
|
||||
assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended");
|
||||
assert_eq!(exposure(&s), -1.0);
|
||||
// undo brings the dropped steps and the cursor back
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_move_the_cursor_with_the_settings() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 3})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_history_resets_the_cursor() {
|
||||
let (mut s, n) = three_edits();
|
||||
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
|
||||
s.execute("history.clear", &json!({})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0))));
|
||||
assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() {
|
||||
let mut s = Session::with_demo();
|
||||
let snap = s.catalog.to_snapshot();
|
||||
let _ = s.drain_log();
|
||||
for v in [1.0, 2.0] {
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
|
||||
}
|
||||
let (n, _) = list(&mut s);
|
||||
s.execute("history.restore", &json!({"index": n - 2})).unwrap();
|
||||
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
|
||||
let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap();
|
||||
c.replay(&log).unwrap();
|
||||
assert_eq!(c.to_snapshot(), s.catalog.to_snapshot());
|
||||
let id = s.active().unwrap();
|
||||
assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2));
|
||||
// a photo written before the cursor existed has no `history_cursor` key: at the latest step
|
||||
let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap();
|
||||
v.as_object_mut().unwrap().remove("history_cursor");
|
||||
let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap();
|
||||
assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1)));
|
||||
}
|
||||
@@ -21,6 +21,9 @@ fn write_png(path: &Path, seed: u8) {
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
|
||||
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value, key: &str) -> usize {
|
||||
@@ -309,6 +312,27 @@ fn recently_added_covers_recent_imports_newest_first() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_added_keeps_an_import_batch_in_file_order() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut s = Session::new();
|
||||
// one import batch: one `imported` string, ids running opposite to the names
|
||||
let mut by_name = Vec::new();
|
||||
for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() {
|
||||
let id = PhotoId(5000 - n as u64);
|
||||
let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
by_name.push(name.to_string());
|
||||
}
|
||||
s.clock = Box::new(|| "2026-10-01T09:00:00".to_string());
|
||||
for ascending in [true, false] {
|
||||
s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap();
|
||||
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
|
||||
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
|
||||
assert_eq!(names, by_name, "ascending: {ascending}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
|
||||
/// import order), and a metadata preset on every photo.
|
||||
#[test]
|
||||
@@ -560,6 +584,14 @@ fn cube_luts_become_profiles() {
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
s.execute("profile.favorite", &json!({"id": id})).unwrap();
|
||||
assert!(s.profile_favorites.contains(&id));
|
||||
// `profiles.list` lists it next to the built-in profiles (#618)
|
||||
let listed = |s: &mut Session| {
|
||||
let list = s.execute("profiles.list", &json!({})).unwrap();
|
||||
let list = list.as_array().unwrap();
|
||||
assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed");
|
||||
list.iter().find(|p| p["id"] == id.as_str()).cloned()
|
||||
};
|
||||
assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true})));
|
||||
// the library keeps it
|
||||
drop(s);
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
@@ -569,8 +601,10 @@ fn cube_luts_become_profiles() {
|
||||
assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen");
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
|
||||
assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen");
|
||||
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_none());
|
||||
assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -1404,14 +1438,20 @@ fn smart_preview_look_marker_is_read_safely() {
|
||||
let dir = temp_dir("smartlook-marker");
|
||||
assert!(!crate::smart::look_checked(&dir), "no marker");
|
||||
let cur = crate::camera_preview::LOOK_VERSION;
|
||||
let enc = crate::smart::ENCODE_VERSION;
|
||||
for (text, checked) in [
|
||||
(String::new(), false),
|
||||
("banana".into(), false),
|
||||
("-1".into(), false),
|
||||
(format!("{}", cur.saturating_sub(1)), false),
|
||||
("9".repeat(200), false),
|
||||
(format!("{cur}\n"), true),
|
||||
(format!(" {}\n", cur + 1), true),
|
||||
// Written before the encode version was recorded: only says the look was checked, so the
|
||||
// folder is scanned once more and the old-encoding proxies are rebuilt.
|
||||
(format!("{cur}\n"), false),
|
||||
(format!("{cur} {}\n", enc.saturating_sub(1)), false),
|
||||
(format!("{cur} banana\n"), false),
|
||||
(format!("{cur} {enc}\n"), true),
|
||||
(format!(" {} {}\n", cur + 1, enc + 1), true),
|
||||
] {
|
||||
std::fs::write(dir.join("look-version"), &text).unwrap();
|
||||
assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}");
|
||||
|
||||
@@ -22,6 +22,9 @@ fn write_png(path: &Path, seed: u8) {
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
// undated files take the file's date: the tests' clock time (2026-01-14T05:58:48)
|
||||
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328);
|
||||
std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
fn xmp(rating: u8) -> String {
|
||||
@@ -500,3 +503,30 @@ fn copies_run_side_by_side_with_the_same_outcome() {
|
||||
assert_eq!(first.file_name, "IMG_1.png");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_keeps_the_original_when_the_copy_changed_to_the_same_length() {
|
||||
use crate::import_move::{finish, place};
|
||||
let base = temp_dir("finish-recheck");
|
||||
let (src, dest) = (base.join("card/IMG_1.jpg"), base.join("dest"));
|
||||
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
||||
std::fs::write(&src, b"original bytes").unwrap();
|
||||
inject(Fault::NoLink);
|
||||
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
|
||||
// unchanged copy: the original goes
|
||||
assert!(finish(&placed).is_ok());
|
||||
assert!(!src.exists());
|
||||
// a copy replaced by other bytes of the same length: the original stays
|
||||
std::fs::write(&src, b"original bytes").unwrap();
|
||||
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
|
||||
std::fs::write(&placed.dst, b"different byte").unwrap();
|
||||
assert!(finish(&placed).is_err());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), b"original bytes");
|
||||
// an original edited meanwhile to other bytes of the same length: it stays too
|
||||
let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
|
||||
std::fs::write(&src, b"edited bytes!").unwrap();
|
||||
assert!(finish(&placed).is_err());
|
||||
assert_eq!(std::fs::read(&src).unwrap(), b"edited bytes!");
|
||||
inject(Fault::None);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -155,13 +155,22 @@ fn odd_folders_never_fail_the_check() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
|
||||
std::fs::create_dir_all(&bad).unwrap();
|
||||
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
|
||||
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
|
||||
let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe"));
|
||||
// APFS (macOS) refuses names that are not valid UTF-8 (EILSEQ): there the non-UTF-8 cases are skipped and
|
||||
// the symlink cases below still run on a plain-named catalog folder
|
||||
let probe = root.join(std::ffi::OsStr::from_bytes(b"\xff"));
|
||||
let utf8_only = std::fs::create_dir(&probe).is_err();
|
||||
let _ = std::fs::remove_dir(&probe);
|
||||
if !utf8_only {
|
||||
let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata"));
|
||||
std::fs::create_dir_all(&bad).unwrap();
|
||||
std::fs::write(root.join("Cat.lrcat"), b"x").unwrap();
|
||||
assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name");
|
||||
}
|
||||
let lr = root.join(if utf8_only { std::ffi::OsStr::new("lr") } else { std::ffi::OsStr::from_bytes(b"\xff\xfe") });
|
||||
std::fs::create_dir_all(&lr).unwrap();
|
||||
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
|
||||
if !utf8_only {
|
||||
std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap();
|
||||
}
|
||||
std::fs::write(lr.join("b.lrcat"), b"x").unwrap();
|
||||
assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped");
|
||||
// a symlink to a catalog folder is one too; a dangling one is not
|
||||
|
||||
@@ -67,6 +67,82 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An HDR merge keeps the reference frame's lens corrections. The sources carry an off-centre `WarpRectilinear` and
|
||||
/// `FixVignetteRadial` for their active area and have a default crop; the merged DNG must give "Enable Profile
|
||||
/// Corrections" the same correction as the reference photo, upright and for a vertical bracket (whose DNG keeps the
|
||||
/// pixels unturned).
|
||||
#[test]
|
||||
fn hdr_merge_keeps_the_reference_frames_lens_corrections() {
|
||||
use lightcraft_geom::{Orientation, Point};
|
||||
use lightcraft_pipeline::optics::{embedded_vignette_gain, embedded_warp};
|
||||
use lightcraft_raw::Opcode;
|
||||
for orientation in [Orientation::Normal, Orientation::Rotate90] {
|
||||
let dir = temp_dir(&format!("hdr-lens-{orientation:?}"));
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
|
||||
let mut raw = lightcraft_raw::decode(&b).unwrap();
|
||||
raw.crop = lightcraft_raw::Rect::new(10, 6, 452, 304);
|
||||
raw.orientation = orientation;
|
||||
raw.metadata.orientation = Some(orientation);
|
||||
raw.opcodes.list3 = vec![
|
||||
Opcode::WarpRectilinear { planes: vec![[0.96, 0.05, -0.012, 0.002, 0.001, -0.0005]], center: [0.48, 0.53] },
|
||||
Opcode::FixVignetteRadial { k: [0.35, -0.05, 0.01, 0.0, 0.0], center: [0.48, 0.53] },
|
||||
];
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let ids = import(&mut s, paths);
|
||||
let r = s.execute("merge.hdr", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["reference"].as_u64(), Some(ids[1]), "{r}");
|
||||
let merged = s.catalog.photo(lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap())).unwrap().clone();
|
||||
let reference = s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().clone();
|
||||
let expect = if orientation == Orientation::Normal { (452, 304) } else { (304, 452) };
|
||||
assert_eq!(((merged.width, merged.height), (reference.width, reference.height)), (expect, expect));
|
||||
let a = reference.embedded_lens.expect("the source's correction");
|
||||
let b = merged.embedded_lens.expect("the merged DNG carries the reference frame's correction");
|
||||
let (wa, wb) = (a.warp.unwrap(), b.warp.unwrap());
|
||||
let (va, vb) = (a.vignette.unwrap(), b.vignette.unwrap());
|
||||
let (w, h) = (expect.0 as f64, expect.1 as f64);
|
||||
for (x, y) in [(0.0, 0.0), (w, h), (w, 0.0), (0.0, h), (w * 0.3, h * 0.7), (w * 0.5, h * 0.5)] {
|
||||
let p = Point::new(x, y);
|
||||
for ch in 0..3 {
|
||||
let (pa, pb) = (embedded_warp(&wa, p, ch, w, h), embedded_warp(&wb, p, ch, w, h));
|
||||
assert!((pa.x - pb.x).abs() < 1e-6 && (pa.y - pb.y).abs() < 1e-6, "{orientation:?} {p:?}: {pa:?} vs {pb:?}");
|
||||
}
|
||||
let (ga, gb) = (embedded_vignette_gain(&va, p, w, h), embedded_vignette_gain(&vb, p, w, h));
|
||||
assert!((ga - gb).abs() < 1e-9, "{orientation:?} {p:?}: {ga} vs {gb}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
|
||||
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
|
||||
#[test]
|
||||
fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() {
|
||||
let dir = temp_dir("hdr-colour");
|
||||
let neutral = [0.5, 1.0, 0.7];
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() {
|
||||
// the same pixels with a white balance but no colour matrices
|
||||
let mut raw = lightcraft_raw::decode(&b).unwrap();
|
||||
raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() };
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let ids = import(&mut s, paths);
|
||||
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
|
||||
let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color);
|
||||
let got = merged.color.as_shot_neutral.expect("the sources' white balance");
|
||||
assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panorama_command_with_auto_crop() {
|
||||
let dir = temp_dir("pano");
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays
|
||||
//! responsive: commands that don't touch presets never re-serialize them, and opening or importing
|
||||
//! doesn't compare every preset with every other.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_develop::Preset;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn many(n: usize) -> Vec<Preset> {
|
||||
(0..n)
|
||||
.map(|i| Preset {
|
||||
id: format!("user.coll-{}.look-{i}", i / 100),
|
||||
name: format!("Look {i}"),
|
||||
group: format!("Collection {}", i / 100),
|
||||
settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}),
|
||||
favorite: false,
|
||||
builtin: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_commands_do_not_reserialize_unchanged_presets() {
|
||||
let lib = temp_dir("no-reserialize");
|
||||
let mut s = Session::new();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let added = s.add_presets(many(2000));
|
||||
assert_eq!(added.len(), 2000);
|
||||
s.persist().unwrap();
|
||||
assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved");
|
||||
let before = crate::library::presets_serialized();
|
||||
for i in 0..20 {
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
|
||||
}
|
||||
assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone");
|
||||
// a preset change is still saved
|
||||
let pid = added[7].clone();
|
||||
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
|
||||
assert!(crate::library::presets_serialized() > before);
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let mut t = Session::new();
|
||||
t.open_library(&lib, false).unwrap();
|
||||
assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted");
|
||||
assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_list_generation_follows_mutable_access_only() {
|
||||
let mut s = Session::new();
|
||||
let g0 = s.presets.generation();
|
||||
let _ = s.presets.iter().filter(|p| p.builtin).count();
|
||||
let _ = s.presets.len();
|
||||
assert_eq!(s.presets.generation(), g0, "reads keep the generation");
|
||||
s.presets.push(many(1).remove(0));
|
||||
let g1 = s.presets.generation();
|
||||
assert_ne!(g1, g0);
|
||||
for p in &mut s.presets {
|
||||
p.favorite = false;
|
||||
}
|
||||
assert_ne!(s.presets.generation(), g1);
|
||||
// generations are unique across sessions (UI caches key on them alone)
|
||||
let other = Session::new();
|
||||
assert_ne!(other.presets.generation(), s.presets.generation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_thousands_of_presets_is_not_quadratic() {
|
||||
let mut s = Session::new();
|
||||
let t = Instant::now();
|
||||
let added = s.add_presets(many(20_000));
|
||||
assert_eq!(added.len(), 20_000);
|
||||
// the same presets again are all skipped as duplicates
|
||||
assert!(s.add_presets(many(20_000)).is_empty());
|
||||
// a clashing id with different settings gets a fresh, unique one
|
||||
let mut clash = many(1).remove(0);
|
||||
clash.settings = json!({"light": {"exposure": 3.0}});
|
||||
let id = s.add_presets(vec![clash]).remove(0);
|
||||
assert_ne!(id, "user.coll-0.look-0");
|
||||
assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1);
|
||||
// quadratic was ~200 M comparisons here (tens of seconds in debug builds)
|
||||
assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test
|
||||
/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied
|
||||
/// into a temporary library; the original is only read.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn presets_scale_bench() {
|
||||
let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else {
|
||||
eprintln!("set LIGHTCRAFT_BENCH_PRESETS");
|
||||
return;
|
||||
};
|
||||
let lib = temp_dir("bench");
|
||||
std::fs::copy(&src, lib.join("presets.json")).unwrap();
|
||||
let mut s = Session::new();
|
||||
let t = Instant::now();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len());
|
||||
let t = Instant::now();
|
||||
for i in 0..20 {
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
|
||||
}
|
||||
eprintln!("develop.set: {:?} per command", t.elapsed() / 20);
|
||||
let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap();
|
||||
let t = Instant::now();
|
||||
s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap();
|
||||
eprintln!("preset.apply: {:?}", t.elapsed());
|
||||
let t = Instant::now();
|
||||
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
|
||||
eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed());
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -115,6 +115,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
|
||||
}
|
||||
let st = s.segmenter.download_status();
|
||||
assert!(!st.finished);
|
||||
assert!(!st.cancelled, "a failure is not a cancel");
|
||||
let e = st.error.unwrap();
|
||||
assert!(e.contains("not found"), "{e}");
|
||||
assert!(!s.segmenter.installed());
|
||||
@@ -272,9 +273,35 @@ fn a_running_model_download_shows_in_activity_and_cancels() {
|
||||
assert!(t.elapsed() < Duration::from_secs(10), "the download never stopped");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let e = s.segmenter.download_status().error.unwrap_or_default();
|
||||
let st = s.segmenter.download_status();
|
||||
assert!(st.cancelled, "the app tells a cancel by the flag (#1046): {st:?}");
|
||||
let e = st.error.unwrap_or_default();
|
||||
assert!(e.contains("cancelled"), "{e}");
|
||||
assert!(!s.segmenter.installed());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(&mirrors);
|
||||
}
|
||||
|
||||
/// Frontends decide on the error's kind, not its words (#1046): a missing model is
|
||||
/// `ModelMissing(Sam3)` whether the app queues requests (background) or a command waits (CLI, MCP);
|
||||
/// a build without AI masks is not a missing model (there is nothing to download).
|
||||
#[test]
|
||||
fn a_missing_model_is_model_missing_by_kind() {
|
||||
use crate::{AiModel, ErrorKind};
|
||||
for background in [true, false] {
|
||||
let mut s = Session::with_demo();
|
||||
s.segmenter.background = background;
|
||||
s.segmenter.dir = Some(tmp(&format!("kind-{background}")));
|
||||
for (cmd, p) in
|
||||
[("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
|
||||
{
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
if Segmenter::AVAILABLE {
|
||||
assert_eq!(e.kind(), ErrorKind::ModelMissing(AiModel::Sam3), "{cmd} (background {background}): {e}");
|
||||
assert!(e.to_string().starts_with(NOT_INSTALLED), "the message is unchanged: {e}");
|
||||
} else {
|
||||
assert_eq!(e.kind(), ErrorKind::Other, "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,23 @@ use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends.
|
||||
#[test]
|
||||
fn up_and_down_move_by_rows_and_stop_at_the_ends() {
|
||||
let mut s = Session::with_demo();
|
||||
let vis = s.visible_cloned();
|
||||
assert!(vis.len() >= 6);
|
||||
s.selection = crate::Selection::single(vis[0]);
|
||||
s.execute("library.down", &json!({"rows": 4})).unwrap();
|
||||
assert_eq!(s.selection.active, Some(vis[4]));
|
||||
s.execute("library.up", &json!({})).unwrap();
|
||||
assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)");
|
||||
s.execute("library.up", &json!({"rows": 99})).unwrap();
|
||||
assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo");
|
||||
s.execute("library.down", &json!({"rows": u64::MAX})).unwrap();
|
||||
assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow");
|
||||
}
|
||||
|
||||
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
|
||||
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,26 @@ use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::rename::{MoveFs, RealFs};
|
||||
|
||||
/// Whether `dir` is on a case-insensitive volume (the default on macOS and Windows): its path in upper case still resolves.
|
||||
pub(crate) fn case_insensitive(dir: &Path) -> bool {
|
||||
Path::new(&dir.to_string_lossy().to_uppercase()).exists()
|
||||
}
|
||||
|
||||
/// The same path, or the same file under another spelling (`a.XMP` and `a.xmp` on a case-insensitive volume). Fault
|
||||
/// injectors match this way, so an injected failure fires whichever spelling the code under test uses for a file; on a
|
||||
/// case-sensitive volume only identical paths match.
|
||||
pub(crate) fn same_path_or_file(a: &Path, b: &Path) -> bool {
|
||||
a == b || RealFs.same_file(a, b)
|
||||
}
|
||||
|
||||
/// The same path, or on a case-insensitive volume a path that differs only in ASCII case: the code under test may spell
|
||||
/// a sidecar `.XMP` for a file stored as `.xmp` there, while on a case-sensitive volume it offers only the spelling that
|
||||
/// exists.
|
||||
pub(crate) fn same_path_for_volume(a: &Path, b: &Path) -> bool {
|
||||
a == b || (a.parent().is_some_and(case_insensitive) && a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy()))
|
||||
}
|
||||
|
||||
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id()));
|
||||
@@ -175,6 +195,71 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A sidecar with only `crs:` develop settings, as LRTimelapse writes them (issue #934).
|
||||
fn crs_exposure_sidecar(exposure: &str) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="15.0" crs:Exposure2012="{exposure}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Issue #934: with auto-write on, Read Metadata from File wrote what it had just read straight back
|
||||
/// into the sidecar, adding our `lc:settings`. Reading leaves the file byte for byte; an edit made
|
||||
/// after it is still written.
|
||||
#[test]
|
||||
fn reading_metadata_leaves_the_sidecar_alone_with_auto_write_on() {
|
||||
let src = temp_dir("read-no-autowrite");
|
||||
write_png(&src.join("frame.png"), 6);
|
||||
let sidecar = src.join("frame.xmp");
|
||||
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
|
||||
// something for the read to change back (auto-write is still off, so the file stays as it is)
|
||||
s.execute("develop.reset", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let before = std::fs::read(&sidecar).unwrap();
|
||||
let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5, "the sidecar was read");
|
||||
let now = std::fs::read(&sidecar).unwrap();
|
||||
assert!(now == before, "reading leaves the sidecar byte for byte, but it is now:\n{}", String::from_utf8_lossy(&now));
|
||||
// a develop edit afterwards is still written (only the read is exempt)
|
||||
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.9})).unwrap();
|
||||
let after = std::fs::read_to_string(&sidecar).unwrap();
|
||||
match crate::sidecar::parse_sidecar(&after, crate::crs::Target::Rendered).unwrap().develop {
|
||||
Some(crate::sidecar::DevelopPatch::Full(d)) => assert_eq!(d.light.exposure, 0.9),
|
||||
other => panic!("the edit wasn't auto-written: {other:?}\n{after}"),
|
||||
}
|
||||
assert!(after.contains(r#"crs:Exposure2012="0.5""#), "the other app's settings are kept: {after}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Issue #934, the round trip the write-back broke: after a read with auto-write on, another app
|
||||
/// changes `crs:Exposure2012` from 0.5 to 1.2 (keeping the rest of the file), and the next read
|
||||
/// takes 1.2.
|
||||
#[test]
|
||||
fn a_crs_change_after_a_read_is_taken_with_auto_write_on() {
|
||||
let src = temp_dir("read-then-crs-change");
|
||||
write_png(&src.join("frame.png"), 7);
|
||||
let sidecar = src.join("frame.xmp");
|
||||
std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5);
|
||||
// the other app edits the file as it is now
|
||||
let text = std::fs::read_to_string(&sidecar).unwrap();
|
||||
let changed = text.replace(r#"crs:Exposure2012="0.5""#, r#"crs:Exposure2012="1.2""#);
|
||||
assert_ne!(changed, text);
|
||||
std::fs::write(&sidecar, changed).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 1.2, "the second read takes the other app's exposure");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar
|
||||
/// and our own starting look stands in for them: importing one gives no camera-profile warning.
|
||||
#[test]
|
||||
|
||||
@@ -57,6 +57,8 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
|
||||
if let Ok(c) = std::fs::canonicalize(root) {
|
||||
linked.insert(c);
|
||||
}
|
||||
// Include aliases to the library in the exclusion, not just its lexical path.
|
||||
let skip_canonical = skip.and_then(|p| std::fs::canonicalize(p).ok());
|
||||
// (path, depth of the folder it is in, is a folder); popped in path order
|
||||
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
|
||||
while let Some((path, depth, is_dir)) = stack.pop() {
|
||||
@@ -66,7 +68,7 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip.is_some_and(|s| path == s) {
|
||||
if skip.is_some_and(|s| path == s) || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) {
|
||||
continue;
|
||||
}
|
||||
if depth >= limits.max_depth {
|
||||
@@ -165,6 +167,31 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Folder imports must not revisit the user's own library through symlinks
|
||||
/// or through an alias to the library's parent.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn skipped_library_stays_skipped_through_symlinks_and_ancestors() {
|
||||
let d = tmp("skip-alias");
|
||||
let root = d.join("incoming");
|
||||
let library = d.join("library");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(library.join("Originals")).unwrap();
|
||||
std::fs::write(root.join("ordinary.jpg"), b"photo").unwrap();
|
||||
std::fs::write(library.join("Originals").join("library-only.jpg"), b"original").unwrap();
|
||||
std::os::unix::fs::symlink(&library, root.join("library-link")).unwrap();
|
||||
std::os::unix::fs::symlink(&d, root.join("parent-link")).unwrap();
|
||||
|
||||
let photos = files_in(&root, Some(&library), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
|
||||
assert!(photos.files.contains(&root.join("ordinary.jpg")));
|
||||
assert!(!photos.files.iter().any(|p| p.ends_with("library-only.jpg")), "{photos:?}");
|
||||
|
||||
// Scanning a subfolder already inside the excluded library is also forbidden.
|
||||
let inside = files_in(&library.join("Originals"), Some(&library), Limits::default(), |_| true);
|
||||
assert!(inside.files.is_empty(), "{inside:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A link back up the tree (a loop) is followed at most once: the walk ends.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
|
||||
@@ -20,8 +20,9 @@ fn a_preview_reads_only_the_header_and_the_preview() {
|
||||
assert!(file_len > 0, "empty file");
|
||||
|
||||
let started = Instant::now();
|
||||
let window =
|
||||
lightcraft_engine::files::preview_window(&path).expect("the file reads").expect("it carries an embedded preview the header can point at");
|
||||
let window = lightcraft_engine::files::preview_window(&path, usize::MAX)
|
||||
.expect("the file reads")
|
||||
.expect("it carries an embedded preview the header can point at");
|
||||
let elapsed = started.elapsed();
|
||||
let (_, preview, read) = window;
|
||||
|
||||
@@ -32,10 +33,16 @@ fn a_preview_reads_only_the_header_and_the_preview() {
|
||||
read.total,
|
||||
frac * 100.0,
|
||||
elapsed.as_secs_f64(),
|
||||
if read.header_path { " [header path]" } else { " [whole file]" }
|
||||
if read.planned {
|
||||
" [plan]"
|
||||
} else if read.header_path {
|
||||
" [header path]"
|
||||
} else {
|
||||
" [whole file]"
|
||||
}
|
||||
);
|
||||
|
||||
assert!(read.header_path, "the header path must answer for a raw whose pointer is in its header");
|
||||
assert!(read.header_path, "the header or the plan must answer for a raw whose preview is not the whole file");
|
||||
assert_eq!(read.total, file_len, "the report covers the whole file");
|
||||
assert!(frac < 0.5, "a preview must not cost most of the file: read {} of {} bytes", read.read, read.total);
|
||||
assert!(preview.starts_with(&[0xff, 0xd8]), "the preview bytes are a JPEG: {:?}", preview.get(..4));
|
||||
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user