Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3d392b499 | ||
|
|
ae9b09d3c2 | ||
|
|
c94ebae470 | ||
|
|
4ee6a4888b | ||
|
|
68bacba288 | ||
|
|
8a640dc70b | ||
|
|
478f20054b | ||
|
|
d8f10a1ef7 | ||
|
|
643bc7f909 | ||
|
|
68c924047d | ||
|
|
2397927681 | ||
|
|
ece9bd83e4 | ||
|
|
7693cbf3ae | ||
|
|
2e877ad476 | ||
|
|
b709180587 | ||
|
|
1d70cf6358 | ||
|
|
2f0931d3ca | ||
|
|
a1035f1372 | ||
|
|
d5b8d52e1d | ||
|
|
6568a7ae16 | ||
|
|
d752076339 | ||
|
|
702f90bfc9 | ||
|
|
fa3dfbbc4a | ||
|
|
84d6849cf3 | ||
|
|
ed1d641f22 | ||
|
|
692ab7f7c2 | ||
|
|
32253d862a | ||
|
|
4e78514948 | ||
|
|
bac147a460 | ||
|
|
2181c69162 | ||
|
|
963d4f327a | ||
|
|
97f8b8c87c | ||
|
|
8df468bfc6 | ||
|
|
8ca6a835ec | ||
|
|
251397694e | ||
|
|
94808da369 | ||
|
|
edfe7c6351 | ||
|
|
95d519ae64 | ||
|
|
1c473ed64c | ||
|
|
cb8aa0b017 | ||
|
|
d3c024e4df | ||
|
|
74a0b5796a | ||
|
|
4764e85f37 | ||
|
|
0ab59399c8 | ||
|
|
6bbceb2fa8 | ||
|
|
d6583cebf8 | ||
|
|
cc9deaf64b | ||
|
|
76fd9b9ae8 | ||
|
|
62b1ba04d0 | ||
|
|
e4873dd480 | ||
|
|
9b4047b1d4 | ||
|
|
ac19978d37 | ||
|
|
6eb04f9a1c | ||
|
|
a681a3c667 | ||
|
|
c7e259b3ce | ||
|
|
bcbcf3b968 | ||
|
|
e6ef0aa368 | ||
|
|
695eb6acc5 | ||
|
|
5475691ea4 | ||
|
|
5e430d057f | ||
|
|
b089188ee5 | ||
|
|
9e0f00c557 | ||
|
|
a0b1da61aa | ||
|
|
e9892a6f9d | ||
|
|
b297049e52 | ||
|
|
54c4549fe9 | ||
|
|
14e2a480f0 | ||
|
|
33f03ba0f7 | ||
|
|
18bd8fd881 | ||
|
|
a39567306b | ||
|
|
14b111f6f5 | ||
|
|
d3d55e8327 | ||
|
|
ecf1236b7c | ||
|
|
f28b9c46d3 | ||
|
|
6aabe43386 | ||
|
|
b9b6bf57de | ||
|
|
64b3361125 | ||
|
|
44684b7950 | ||
|
|
61c3042efe | ||
|
|
2fc2419fa1 | ||
|
|
5cec4d0720 | ||
|
|
b3860eb734 | ||
|
|
931b0dd067 | ||
|
|
fa7cd628e5 | ||
|
|
25aff046f3 | ||
|
|
9f84c3b60d | ||
|
|
8e808e8d92 | ||
|
|
690eefb088 | ||
|
|
536b4fffec | ||
|
|
b01902bbae | ||
|
|
dca358d435 | ||
|
|
1543fef701 | ||
|
|
ac19325c57 | ||
|
|
3ba3120637 | ||
|
|
b56f052a59 | ||
|
|
5388d20088 | ||
|
|
b2a5997e55 | ||
|
|
43799cc903 | ||
|
|
a6369c4009 | ||
|
|
6435019603 | ||
|
|
99e9f25796 | ||
|
|
179461aee3 | ||
|
|
3e5439cc94 | ||
|
|
c9e12bd9ff | ||
|
|
cd75e11841 | ||
|
|
5fddd79583 | ||
|
|
035c378758 | ||
|
|
9fe9beffbc | ||
|
|
e325de9db4 | ||
|
|
751d25a7f7 | ||
|
|
6a122dc684 | ||
|
|
ae029e0851 | ||
|
|
f2a9888f75 | ||
|
|
765e94e0a1 | ||
|
|
1f1374fd46 | ||
|
|
ade1a86a03 | ||
|
|
79e0e98321 | ||
|
|
a10bc04b8b | ||
|
|
c8f676252f | ||
|
|
58822f6229 | ||
|
|
77d8558fc4 | ||
|
|
0fa8070647 | ||
|
|
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 | ||
|
|
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 | ||
|
|
5343fd10ff | ||
|
|
9e41805a12 | ||
|
|
e4f92df78c | ||
|
|
c41b1e311d | ||
|
|
a77b0cc472 | ||
|
|
9e2f383166 | ||
|
|
a90cdcd2f3 | ||
|
|
f4761b459c | ||
|
|
3b306259e5 | ||
|
|
76a2899383 | ||
|
|
995d5ea49b | ||
|
|
ea51574172 | ||
|
|
f01d3304e2 | ||
|
|
45b174a781 | ||
|
|
4ebb934c56 | ||
|
|
9cf47c86fe | ||
|
|
7fe8f8dbd1 | ||
|
|
4ff1d9761d | ||
|
|
9978dc739d | ||
|
|
0d5f8b1ea4 | ||
|
|
705c59cb42 | ||
|
|
6bc25940b8 | ||
|
|
bd3b058a8f | ||
|
|
f4a205b4ff | ||
|
|
255b23816d | ||
|
|
4a0d04b994 | ||
|
|
4ec3494733 | ||
|
|
e4a248c91f | ||
|
|
c54328b2d3 | ||
|
|
eae1eb5ba2 | ||
|
|
870eaa74e6 | ||
|
|
fce79feadb | ||
|
|
44f86dc855 | ||
|
|
47b5e0ae1b | ||
|
|
8df1ad0392 | ||
|
|
0dd6593d9d | ||
|
|
37b26c56a3 | ||
|
|
d761e1b231 | ||
|
|
155ad70c6e | ||
|
|
10ebeccdc8 | ||
|
|
2e5a9f92ca | ||
|
|
acdfa08f00 | ||
|
|
b89fb8bbab | ||
|
|
e6af53d46c | ||
|
|
d1e20f0292 | ||
|
|
b04c2c831c | ||
|
|
e1016acc0f | ||
|
|
8c4898ec04 | ||
|
|
59011d114f | ||
|
|
f7ffa1433c | ||
|
|
33686e6951 | ||
|
|
45a6a42ebf | ||
|
|
a0e422ce4a | ||
|
|
56a62f6d8b | ||
|
|
6b22c5ca25 | ||
|
|
c53b61732d | ||
|
|
76fc27a7bc | ||
|
|
c9d131bfbc | ||
|
|
8339028a80 | ||
|
|
6308ca1088 | ||
|
|
11f41cbe97 | ||
|
|
7ff683f037 | ||
|
|
477c078b35 | ||
|
|
5ba413baf2 | ||
|
|
421000916e | ||
|
|
860b0d69c6 | ||
|
|
111bb42aa3 | ||
|
|
8380415222 | ||
|
|
d7f868fc6d | ||
|
|
da008ce53c | ||
|
|
5df7f733d3 | ||
|
|
7ecb0b0191 | ||
|
|
17dfa9709e | ||
|
|
3b0131aaea | ||
|
|
2efa01767f | ||
|
|
05939cbc54 | ||
|
|
b8d39063db | ||
|
|
5861591307 | ||
|
|
6c18c0a7c6 | ||
|
|
95007492cc | ||
|
|
5ce115a505 | ||
|
|
62ece65ac9 | ||
|
|
44acde3a41 | ||
|
|
c04eec3f9d | ||
|
|
b6ad706aa6 | ||
|
|
31734dea86 | ||
|
|
76cf8ca4c9 | ||
|
|
4e6cd5d7b9 | ||
|
|
9dc0d0b62b | ||
|
|
efd29ef21a | ||
|
|
72d77d7b41 | ||
|
|
2fedd92077 | ||
|
|
40492d238e | ||
|
|
8d319563ca | ||
|
|
edb95894b4 | ||
|
|
16bbaff14c | ||
|
|
db8b82c6d7 | ||
|
|
7c4fd3abaa | ||
|
|
7afbc3ecb3 | ||
|
|
0f9415b320 | ||
|
|
27d2bfda86 | ||
|
|
d098ffac42 | ||
|
|
ae2d936c4d | ||
|
|
96988afe6d | ||
|
|
1f3bfbdb25 | ||
|
|
9847383bf2 | ||
|
|
ab86ed7c84 | ||
|
|
b46adf0640 | ||
|
|
c97b2790d3 | ||
|
|
82f74e31fd | ||
|
|
1d11e5bd89 | ||
|
|
06ef27bb37 | ||
|
|
f7730b5877 | ||
|
|
34b55101fe | ||
|
|
3ac2b21d9e | ||
|
|
5ced33fd9a | ||
|
|
0d022b7506 | ||
|
|
38f16ff1b4 | ||
|
|
33a29dcdc3 | ||
|
|
36cd72bb33 | ||
|
|
605641ab04 | ||
|
|
9efc0950cf | ||
|
|
fbc7ba042b | ||
|
|
4e473b2b37 | ||
|
|
63059a4303 | ||
|
|
0ea19a8a62 | ||
|
|
47fe7f70be | ||
|
|
cb5cd22bd3 | ||
|
|
cba621e55c | ||
|
|
4f9b77cad9 | ||
|
|
814e35b103 | ||
|
|
8df7b008c0 | ||
|
|
0449a3fc51 | ||
|
|
1ac1bc009b | ||
|
|
ee2cd6f4bb | ||
|
|
17e1a34d14 | ||
|
|
4b93c90024 | ||
|
|
e2a38c26e0 | ||
|
|
761a91c233 | ||
|
|
a33dc70a3c | ||
|
|
bb4cdeed2a | ||
|
|
805a15ae9b | ||
|
|
69b90ee7c4 | ||
|
|
170f3956a1 | ||
|
|
2bc33268d2 | ||
|
|
0e4b52e29b | ||
|
|
dba63bc9d5 | ||
|
|
69f117632b | ||
|
|
5350a8ed70 | ||
|
|
aa48f6f6e7 | ||
|
|
f9f3fbe4db | ||
|
|
f4d8a428ac | ||
|
|
f9919acc7f | ||
|
|
f8c01f70fb | ||
|
|
8f42f4a96d | ||
|
|
d5d9daa555 | ||
|
|
bda8569f67 | ||
|
|
0a46f615dc | ||
|
|
b23b20638e | ||
|
|
9ecd3309b2 | ||
|
|
5abc39ae7d | ||
|
|
11dd3bd6b3 | ||
|
|
68f75cc07a | ||
|
|
72b02c65b7 | ||
|
|
5bbce8b9fd | ||
|
|
f30ad32d93 | ||
|
|
09461d04c7 | ||
|
|
f98749343f | ||
|
|
95662cfff5 | ||
|
|
14679de823 | ||
|
|
b038c018c2 | ||
|
|
72a7cd80b8 | ||
|
|
0b960d39e3 | ||
|
|
bbd973f321 | ||
|
|
a7382aacfb | ||
|
|
353c1cf566 | ||
|
|
5a60d94f2f | ||
|
|
88e4188e78 | ||
|
|
43662e6959 | ||
|
|
23e046daba | ||
|
|
d94d6856e9 | ||
|
|
b28b05c475 | ||
|
|
8924ea9afd | ||
|
|
75cf78175a | ||
|
|
457c899160 | ||
|
|
48edfb8d3a | ||
|
|
9815ca1310 | ||
|
|
2c8520371c | ||
|
|
44b12efea3 | ||
|
|
5f35b5af17 | ||
|
|
f05a52968f | ||
|
|
e142075998 | ||
|
|
ad3c7315bb | ||
|
|
bac0f75551 | ||
|
|
fe6d7b0192 | ||
|
|
ab240d6ece | ||
|
|
07ae88e923 | ||
|
|
6873aab6eb | ||
|
|
db7e37843e | ||
|
|
80e58ed55b | ||
|
|
00ea386f7f | ||
|
|
385348b258 | ||
|
|
8e61ad6b13 | ||
|
|
4700c8a74e | ||
|
|
484c160c13 | ||
|
|
c722e6bb64 | ||
|
|
a6431c152d | ||
|
|
43f31532a7 | ||
|
|
5bb43a51f7 | ||
|
|
15f6718a2e | ||
|
|
7154ee149b | ||
|
|
fb678b9e35 | ||
|
|
c9c0268f8b | ||
|
|
cbc0e2d7db | ||
|
|
8c84677079 | ||
|
|
a82ecb3753 | ||
|
|
c536986c48 | ||
|
|
c52ff0f014 | ||
|
|
61fef5cfd5 | ||
|
|
38756b89d0 | ||
|
|
789019714a | ||
|
|
d9d1a5a730 | ||
|
|
7707379812 | ||
|
|
32a358180c | ||
|
|
21d71feb04 | ||
|
|
0b61450c7b | ||
|
|
aec298c116 | ||
|
|
dac0d4c6e2 | ||
|
|
0ba118de92 | ||
|
|
bc2cbf41db | ||
|
|
77fcd81dde | ||
|
|
a7e8f1cc26 | ||
|
|
d76a4121e6 | ||
|
|
32e78b352c | ||
|
|
7d27cf8bfb | ||
|
|
6807421f64 | ||
|
|
f032d6e389 | ||
|
|
9e6c4c6fc9 | ||
|
|
6f44b33ca7 | ||
|
|
ffd737ceab | ||
|
|
aa6054cd1a | ||
|
|
9bfabc98b1 | ||
|
|
40d7b86ecb | ||
|
|
8f3f168802 | ||
|
|
a66a133041 | ||
|
|
e90d83ecc3 | ||
|
|
a67d571656 | ||
|
|
7ce13cfd07 | ||
|
|
ba7df9c7fe | ||
|
|
bbedde35cb | ||
|
|
a940dab727 | ||
|
|
8e9c5aff7e | ||
|
|
d6cfab8223 | ||
|
|
5a975d0d75 | ||
|
|
0067879a58 | ||
|
|
0adcb21166 | ||
|
|
93d76a24db | ||
|
|
22a836316d | ||
|
|
88f424e7e7 | ||
|
|
bc5c561dd8 | ||
|
|
5855ab80a6 | ||
|
|
84b8995d7b | ||
|
|
fe92e844d0 | ||
|
|
e1267a7139 | ||
|
|
a3c33658bb | ||
|
|
7216c8589a | ||
|
|
45227de921 | ||
|
|
0fdf6c5785 | ||
|
|
78c5b4d099 | ||
|
|
62dacf2791 | ||
|
|
7a232ada8b | ||
|
|
7765255ec6 | ||
|
|
de6b33c662 | ||
|
|
b4d7cd1a03 | ||
|
|
89516d2e3e | ||
|
|
e7076762c0 | ||
|
|
bf4a271a71 | ||
|
|
be4cbebbd3 | ||
|
|
2c2f28952f | ||
|
|
85b6fdaedd | ||
|
|
6ae35b0a5b | ||
|
|
46ff7bcf4e | ||
|
|
4dd6fb5089 | ||
|
|
dd73753723 | ||
|
|
179941f4ff | ||
|
|
3cd471b32d | ||
|
|
4860f5e8d4 | ||
|
|
4d26a4d7b4 | ||
|
|
2218a53f2b | ||
|
|
2f75b21ac3 | ||
|
|
1be303ca00 | ||
|
|
fa7f1e8d6c | ||
|
|
69d3d4cf91 | ||
|
|
aea5236790 | ||
|
|
9aa9bcb54e | ||
|
|
458b938248 | ||
|
|
1064e9847d | ||
|
|
7b4bdfa68b | ||
|
|
1a146c12b0 | ||
|
|
1bbb57650c | ||
|
|
15397a712e | ||
|
|
6cba574990 | ||
|
|
e45c4625b4 | ||
|
|
f101a09142 | ||
|
|
868ac707dd | ||
|
|
3a4a70fa8f | ||
|
|
01438b8979 | ||
|
|
083685a34d | ||
|
|
732a2f04fd | ||
|
|
7f966965fc | ||
|
|
e363d298d9 | ||
|
|
964d9338ba | ||
|
|
0041aef955 | ||
|
|
9da719c6ed | ||
|
|
d7eb6b2ab4 | ||
|
|
70103a7e55 | ||
|
|
02a21b860d | ||
|
|
d331829770 | ||
|
|
bf49935fe7 | ||
|
|
9cd9e29614 | ||
|
|
19833e56e3 | ||
|
|
5e68d92c9f | ||
|
|
5ac3e556ac | ||
|
|
a64efb149c | ||
|
|
338b67ffbe | ||
|
|
afbc0f9744 | ||
|
|
0bd3f3715d | ||
|
|
6dca631482 | ||
|
|
61df5d304f | ||
|
|
923b1cde5e | ||
|
|
bbcbbebf8e | ||
|
|
ab438b05ca | ||
|
|
e7f8faf77d | ||
|
|
76e633c9e1 | ||
|
|
b59ffcc4c9 | ||
|
|
b8f868830b | ||
|
|
c8bca35210 | ||
|
|
d803d905b4 | ||
|
|
be2dac6d85 | ||
|
|
5bcedbe6fc | ||
|
|
60844cf162 | ||
|
|
0ed5fcd938 | ||
|
|
943448e7c9 | ||
|
|
1b178ca2e5 | ||
|
|
e40bf5792a | ||
|
|
90875cf8f2 | ||
|
|
d35d0a3dd2 | ||
|
|
3c5e2f3cb2 | ||
|
|
3285e0282d | ||
|
|
aa31ecdc56 | ||
|
|
0de789bac9 | ||
|
|
58f7e74f2a | ||
|
|
584a26085c | ||
|
|
9c2bad1146 | ||
|
|
47e9181f69 | ||
|
|
7883333420 | ||
|
|
8166185a0f | ||
|
|
ec1e26545e | ||
|
|
997bd7a4ed | ||
|
|
ea7777107d | ||
|
|
c739f12e45 | ||
|
|
8a60e3cd30 | ||
|
|
94fed226df | ||
|
|
28a773d460 | ||
|
|
9682caae2a | ||
|
|
ea224d899e | ||
|
|
4cce2d169e | ||
|
|
ba65d0c8d1 | ||
|
|
b70ae1741e | ||
|
|
bec920f67d | ||
|
|
ed705a4483 | ||
|
|
977f912df2 | ||
|
|
87cd873d78 | ||
|
|
2ffe2bcea2 | ||
|
|
a4436597ad | ||
|
|
4721627d31 | ||
|
|
216efab838 | ||
|
|
0b1d80f08d | ||
|
|
2bd974d583 | ||
|
|
04f8146d10 | ||
|
|
4a7eb6b338 | ||
|
|
2aa21c2b12 | ||
|
|
d2ec802902 | ||
|
|
02a9f3a5fb | ||
|
|
c606bb4b0e | ||
|
|
4a25abdfa8 | ||
|
|
dd2e916842 | ||
|
|
eb2a0fde05 | ||
|
|
fb6dda44ae | ||
|
|
0f33d2b14a | ||
|
|
3752207ad1 | ||
|
|
7987783a57 | ||
|
|
cf7b55ace8 | ||
|
|
ec39ca1ad6 | ||
|
|
f9bbeb9d00 | ||
|
|
cad9782ca1 | ||
|
|
33f971c3f9 | ||
|
|
9f73f086c9 | ||
|
|
56dfc8d027 | ||
|
|
7a65cc6340 | ||
|
|
b7e791a136 | ||
|
|
4539014a42 | ||
|
|
7f1113c285 | ||
|
|
7962c78bc5 | ||
|
|
fd5d05e256 | ||
|
|
51d98f9256 | ||
|
|
760819eab3 | ||
|
|
5240bdaa6f | ||
|
|
01fc7a5e75 | ||
|
|
20d7d9e981 | ||
|
|
8c3c586b80 | ||
|
|
e974a87cfb | ||
|
|
1c89caa4a1 | ||
|
|
753ca97a4d | ||
|
|
3f3c204987 | ||
|
|
de247ad54f | ||
|
|
e9e8b5a933 | ||
|
|
c3bb8e9916 | ||
|
|
9c8a721389 | ||
|
|
1af0dd4fe3 | ||
|
|
63d9b70356 | ||
|
|
10e097abf6 | ||
|
|
dbf570c711 | ||
|
|
98f65ff0d7 | ||
|
|
af9b1063ca | ||
|
|
4e323ad26f | ||
|
|
533a382619 | ||
|
|
5436951299 | ||
|
|
c545cc5e84 | ||
|
|
4045eaed96 | ||
|
|
b31e0e2ec6 | ||
|
|
17af5ba949 | ||
|
|
aa6dfe3b30 | ||
|
|
9e59e3dcea | ||
|
|
77421aeeda | ||
|
|
5a537d5d39 | ||
|
|
c8dff74948 | ||
|
|
41a61b74fc | ||
|
|
c118f960b1 | ||
|
|
e46e7ab265 | ||
|
|
23af0a9d1c | ||
|
|
3cdf18ba6d | ||
|
|
88de143dd1 | ||
|
|
259ddcc1a8 | ||
|
|
3539de95ca | ||
|
|
57f29bf8e1 | ||
|
|
d267a7acd5 | ||
|
|
37e95dd2fa | ||
|
|
f35663590f | ||
|
|
78550e5de9 | ||
|
|
e7da679cb3 | ||
|
|
0982cae7d5 | ||
|
|
82566817e6 | ||
|
|
4bf125d116 | ||
|
|
b6f6a979e5 | ||
|
|
4a3f9e9b6d | ||
|
|
2ecb495d18 | ||
|
|
4e39a51b24 | ||
|
|
7828f93c32 | ||
|
|
af06f2dbc1 | ||
|
|
34d9c8f2d2 | ||
|
|
b4ab88b751 | ||
|
|
ed49c73ac8 | ||
|
|
e11db8fea0 | ||
|
|
791526aa8c | ||
|
|
d138236291 | ||
|
|
7059f4a565 | ||
|
|
41e95fa78e | ||
|
|
44b126274d | ||
|
|
b4ed033c2d | ||
|
|
987a572512 | ||
|
|
89753f5d67 | ||
|
|
53c056daf3 | ||
|
|
0fc81747cb | ||
|
|
2f35949678 | ||
|
|
b785023073 | ||
|
|
2421f2223b | ||
|
|
bf0656fa95 | ||
|
|
4372750986 | ||
|
|
d0f042c269 | ||
|
|
c2142540fe | ||
|
|
244d279091 | ||
|
|
a891c62eb9 | ||
|
|
771882ae52 | ||
|
|
8b90ca75a5 | ||
|
|
d7bc58a174 | ||
|
|
15850a55b2 | ||
|
|
4a8241dde4 | ||
|
|
f4f7d5c401 | ||
|
|
2c5121ae3b | ||
|
|
d9f63f0b7b | ||
|
|
db03b4a586 | ||
|
|
6f323e01d9 | ||
|
|
6bf2722209 | ||
|
|
41cdec5daf | ||
|
|
630e2edc47 | ||
|
|
835dcf6657 | ||
|
|
ddfb617ff4 | ||
|
|
54b72b4566 | ||
|
|
58853112e7 | ||
|
|
0ea4c50c2a | ||
|
|
93e48f8be7 | ||
|
|
8b9a020084 | ||
|
|
010d9bb5da | ||
|
|
4c4e9b154f | ||
|
|
dea37269ba | ||
|
|
6fcc1e5e28 | ||
|
|
c5551783d2 | ||
|
|
214f1a6af5 | ||
|
|
3a06aab5bc | ||
|
|
6168511f66 | ||
|
|
8329b7be0a | ||
|
|
21b58f4653 | ||
|
|
9a694a4d41 | ||
|
|
6203fa36a1 | ||
|
|
514e828508 | ||
|
|
bde7ff911e | ||
|
|
40e9c56119 | ||
|
|
e282505af2 | ||
|
|
98b83a9a2a | ||
|
|
d23ff731ee | ||
|
|
8a06fa0e5a | ||
|
|
e87863d48d | ||
|
|
3726520504 | ||
|
|
bd32d82a1f | ||
|
|
f41a6357eb | ||
|
|
a1993c7093 | ||
|
|
edb3f29351 | ||
|
|
414332a74d | ||
|
|
1d3b69fe63 | ||
|
|
89dc78b3bf | ||
|
|
96ab5617b8 | ||
|
|
2339b05eec | ||
|
|
06550937e8 | ||
|
|
58eec1f688 | ||
|
|
b5440f167f | ||
|
|
c21c305e55 | ||
|
|
8233d1e412 | ||
|
|
47dfdd3c0e | ||
|
|
f6bb274da3 | ||
|
|
0f8b23875d | ||
|
|
05178a783f | ||
|
|
f7828fdfbc | ||
|
|
4153f62bb5 | ||
|
|
d43efe713a | ||
|
|
f78b60b84a | ||
|
|
90a38718e7 | ||
|
|
9abf0d4210 | ||
|
|
5a21fc9d97 | ||
|
|
f3a3fa6c10 | ||
|
|
7478407d07 | ||
|
|
465c1f87fc | ||
|
|
97e2c7ff51 | ||
|
|
898ecee27c | ||
|
|
cf9d8f7ebb | ||
|
|
f2bb46b7b7 | ||
|
|
82d46b14cd | ||
|
|
c98010cffa | ||
|
|
ac93118911 | ||
|
|
f9d4892c23 | ||
|
|
5c7b655fe6 | ||
|
|
1259506edd | ||
|
|
832d3c8d3d | ||
|
|
dd3f860300 | ||
|
|
c520cf4574 | ||
|
|
1e580466b5 | ||
|
|
5345f0a3cf | ||
|
|
eaa8828bb8 | ||
|
|
4ece03a5df | ||
|
|
454b2bdd2f | ||
|
|
b26d5271b8 | ||
|
|
90361935ba | ||
|
|
c40a5675bb | ||
|
|
034991a081 | ||
|
|
309d659b99 | ||
|
|
37d3621505 | ||
|
|
4e9cffe375 | ||
|
|
060da5bb5e | ||
|
|
a970bdac51 | ||
|
|
2c912d4d1d | ||
|
|
c3b9a13557 | ||
|
|
e2aa4d9762 | ||
|
|
68c044990e | ||
|
|
91e8a0ac37 | ||
|
|
9511132a31 | ||
|
|
02cdb15cfa | ||
|
|
fc4a7270b8 | ||
|
|
8160148a8b | ||
|
|
3d75952562 | ||
|
|
967edfcd80 | ||
|
|
1d8d57ce9a | ||
|
|
04e3b2f1aa | ||
|
|
89091ada49 | ||
|
|
5781b9c9f6 | ||
|
|
4304a7640b | ||
|
|
94d88dd4cc | ||
|
|
efa758a6ba | ||
|
|
405e8d8220 | ||
|
|
6fd57cdcb1 | ||
|
|
1f8447ba79 | ||
|
|
bbf2515c20 | ||
|
|
68c1e78c1b | ||
|
|
6b4839f5fc | ||
|
|
b4c4a436cb | ||
|
|
dbf2c38ef3 | ||
|
|
9ae5a5f882 | ||
|
|
4b5c7ccd3c | ||
|
|
fe02cfe2e1 | ||
|
|
c2bc46d747 | ||
|
|
f5b1c09e42 | ||
|
|
9dabd38941 | ||
|
|
4470d87dcb | ||
|
|
5bb43c79cb | ||
|
|
f2f164f012 | ||
|
|
69c91013e0 | ||
|
|
03aa2edc9d | ||
|
|
d73efbcb47 | ||
|
|
d6c9c32457 | ||
|
|
b501b7fffa | ||
|
|
f577ccf828 | ||
|
|
3e0c412249 | ||
|
|
f83ac3377e | ||
|
|
a3b7c4caf4 | ||
|
|
c732452d35 | ||
|
|
4cb024164e | ||
|
|
f1fa336ab1 | ||
|
|
2e016f73a6 | ||
|
|
228561d4c9 | ||
|
|
decebccb89 | ||
|
|
334868b73a | ||
|
|
8fcc4897c7 | ||
|
|
4010f4a466 | ||
|
|
23ce79009d | ||
|
|
179910c020 | ||
|
|
92730c9246 | ||
|
|
344db72c85 | ||
|
|
5d4ebe5132 | ||
|
|
3100cc1a5c | ||
|
|
199e33043f | ||
|
|
dc37496da4 | ||
|
|
c80318667d | ||
|
|
f48122992b | ||
|
|
ea080e58bd | ||
|
|
9e1abe5a97 | ||
|
|
f0ac4900ba | ||
|
|
fad169fa47 | ||
|
|
6831983a4e | ||
|
|
73175851e7 | ||
|
|
3a145eef4e | ||
|
|
d62a5637d8 | ||
|
|
c01bdbbe9b | ||
|
|
f0689d3271 | ||
|
|
3c9a6d7459 | ||
|
|
44497edca4 | ||
|
|
c743239705 | ||
|
|
debd2ee2ec | ||
|
|
f1c513c20c | ||
|
|
c01eaadbf3 | ||
|
|
41386353e9 | ||
|
|
c0ff290e7e | ||
|
|
3bcc265bf6 | ||
|
|
a29c9f0de5 | ||
|
|
96dcc2f643 | ||
|
|
a677478b92 | ||
|
|
296fc17376 | ||
|
|
10c98459fe | ||
|
|
5a61fc5f67 | ||
|
|
cdca2b9cf8 | ||
|
|
b08f80700f | ||
|
|
7029c8fbbd | ||
|
|
fba3bdef2c | ||
|
|
cd8dfa72c2 | ||
|
|
411daefa68 | ||
|
|
21cd3b75fd | ||
|
|
a33570ca73 | ||
|
|
5ed59252e8 | ||
|
|
7408f11f9a | ||
|
|
57b5317722 | ||
|
|
e529e6f8f4 | ||
|
|
e92095a57a | ||
|
|
9f001c5a53 | ||
|
|
d1ec843863 | ||
|
|
aa57db05a1 | ||
|
|
aec847a2fe | ||
|
|
3b1e72902a | ||
|
|
7cc9980eca | ||
|
|
2e23c767b4 | ||
|
|
a6e59cc5b3 | ||
|
|
6fb1a24c88 | ||
|
|
72d0c4c319 | ||
|
|
17b1bdeeb3 | ||
|
|
52e61d6765 | ||
|
|
9b78f573ab | ||
|
|
f9a6b85a83 | ||
|
|
a92c38fdd4 | ||
|
|
7baf93ecdb | ||
|
|
4efd92b38f | ||
|
|
94ee52088d | ||
|
|
1029e63bde | ||
|
|
50b9660867 | ||
|
|
9311c0516c | ||
|
|
8fd5a6f42f | ||
|
|
3978a0ac9a | ||
|
|
82ff68d4f3 | ||
|
|
5bbb5214f4 | ||
|
|
93cc9afe75 | ||
|
|
6e37b8ec1a | ||
|
|
8f239d62ec | ||
|
|
92de4c1bbc | ||
|
|
968646c059 | ||
|
|
af35fa1bd0 | ||
|
|
cd745e8861 | ||
|
|
7343c8f85f | ||
|
|
13e3406829 | ||
|
|
9453085cef | ||
|
|
b26bb3fa93 | ||
|
|
fb0af3aded | ||
|
|
56ee34fcf8 | ||
|
|
f34c967900 | ||
|
|
b83ba84a79 | ||
|
|
d92ec902c8 | ||
|
|
7e109e880f | ||
|
|
676e8c01ce | ||
|
|
ea1939fb48 | ||
|
|
b401b56da4 | ||
|
|
e88be40e8c | ||
|
|
959cfa65f3 | ||
|
|
44741ecaee | ||
|
|
3ec6b72871 | ||
|
|
ef31741fbb | ||
|
|
4a0ebddcb2 | ||
|
|
46c7660e5c | ||
|
|
128539a939 | ||
|
|
b4c54dbe54 | ||
|
|
330f824c31 | ||
|
|
57f54868de | ||
|
|
29b3f35136 | ||
|
|
877b4fb47f | ||
|
|
2e817a45f4 | ||
|
|
fba436a93b | ||
|
|
b0efba015c | ||
|
|
652475d263 | ||
|
|
8359aaa04c | ||
|
|
e8678bb28e | ||
|
|
a484c66c02 | ||
|
|
11e36bf060 | ||
|
|
cf496fa733 | ||
|
|
1c5e51faf9 | ||
|
|
0a2ece4491 | ||
|
|
a51224e6d4 | ||
|
|
4598d2c186 | ||
|
|
94de255e74 | ||
|
|
872bff96ea | ||
|
|
14bff3706a | ||
|
|
d48f5eaabc | ||
|
|
2ad901e6dc | ||
|
|
f40c7fb204 | ||
|
|
9dcfc6faee | ||
|
|
5c949e01e8 | ||
|
|
4e3b15ff6f | ||
|
|
034ef489ed | ||
|
|
8d4e539d02 | ||
|
|
04ffa7d6b7 | ||
|
|
090f761d71 | ||
|
|
1a82be68ea | ||
|
|
b8d9f82f0e | ||
|
|
633775e5ed | ||
|
|
f61dc889c6 | ||
|
|
59fd64ea6f | ||
|
|
3cd26848f3 | ||
|
|
bb50127de4 | ||
|
|
31abbcee4f | ||
|
|
920658c76d | ||
|
|
850311a884 | ||
|
|
e077172f36 | ||
|
|
7b7807b499 | ||
|
|
2d29dde4d0 | ||
|
|
3c80695e97 | ||
|
|
b0eba68303 | ||
|
|
b5fe62e426 | ||
|
|
7ba1470940 | ||
|
|
a08d0f6b03 | ||
|
|
62aef59178 | ||
|
|
9bac3abb4b | ||
|
|
5865b4c019 | ||
|
|
65516b721c | ||
|
|
451e3cd28c | ||
|
|
c42fc7ac24 | ||
|
|
fa982a4cc6 | ||
|
|
d3730a7024 | ||
|
|
07d5bc3152 | ||
|
|
b0de6d3fef | ||
|
|
7d3c61c56b | ||
|
|
4c826246c3 | ||
|
|
26df5ab60b | ||
|
|
12e1105861 | ||
|
|
6b46c98654 | ||
|
|
c755656bfb | ||
|
|
5d4eea5f7d | ||
|
|
5d86976b82 | ||
|
|
5fa77aa878 | ||
|
|
9101757bfb | ||
|
|
e54acbd176 | ||
|
|
8c0b70b37f | ||
|
|
adcc2830dd | ||
|
|
621dd51cbf | ||
|
|
0614bd4344 | ||
|
|
a6d092ceb0 | ||
|
|
658accc09e | ||
|
|
9d5993e9d1 | ||
|
|
8bf134962e | ||
|
|
485046bf35 | ||
|
|
cfd9c1e20f | ||
|
|
45621f5679 | ||
|
|
e25909938e | ||
|
|
e253ec5fb3 | ||
|
|
cc7bf0e2fd | ||
|
|
265e7e4d50 | ||
|
|
03cb96f86a | ||
|
|
4266b0ad7f | ||
|
|
bec1841561 | ||
|
|
5630734832 | ||
|
|
2dea9109d8 | ||
|
|
41e51a78ef | ||
|
|
632a455e34 | ||
|
|
7d3cd8afe5 | ||
|
|
ec77f60b1b | ||
|
|
20b6eb885e |
No files matched your search
@@ -26,15 +26,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font build input, so the Japanese font tests run (they skip without it).
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
|
||||
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
|
||||
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
|
||||
# searches (a few minutes).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -23,7 +25,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -51,6 +53,11 @@ jobs:
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
|
||||
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
|
||||
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
|
||||
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
|
||||
done
|
||||
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"
|
||||
@@ -66,3 +73,17 @@ jobs:
|
||||
EOF
|
||||
- name: FreeBSD tarball layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
|
||||
windows-install-folder:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Install-folder fallback and precedence (real MSI AppSearch)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x64
|
||||
./packaging/windows/test-install-folder.ps1 -Arch x86
|
||||
+107
-32
@@ -5,8 +5,9 @@
|
||||
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
|
||||
# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
|
||||
# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
|
||||
# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
|
||||
# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
|
||||
# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
|
||||
# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
|
||||
# drafts a release.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -44,7 +45,7 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
@@ -72,17 +73,17 @@ jobs:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -130,23 +131,24 @@ jobs:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
@@ -164,7 +166,7 @@ jobs:
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -187,9 +189,9 @@ jobs:
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
@@ -203,8 +205,8 @@ jobs:
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -228,7 +230,7 @@ jobs:
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -250,8 +252,8 @@ jobs:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -265,12 +267,85 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the tarball
|
||||
run: |
|
||||
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
@@ -282,16 +357,16 @@ jobs:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -316,7 +391,7 @@ jobs:
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
@@ -331,17 +406,17 @@ jobs:
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
@@ -359,7 +434,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
@@ -367,7 +442,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -378,7 +453,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
path: dist
|
||||
|
||||
@@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `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.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
|
||||
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
|
||||
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
|
||||
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
|
||||
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
|
||||
wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
|
||||
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and
|
||||
by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real
|
||||
gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render
|
||||
fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a
|
||||
✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `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.
|
||||
3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for
|
||||
method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that
|
||||
*exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model
|
||||
verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:
|
||||
[`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is
|
||||
wrong or incomplete, downgrade it to 🟡 with a note.
|
||||
4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row
|
||||
tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut).
|
||||
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.
|
||||
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).
|
||||
@@ -55,6 +61,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
|
||||
- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything
|
||||
that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind,
|
||||
label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses
|
||||
`Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row.
|
||||
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
@@ -76,7 +86,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
@@ -103,6 +113,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
|
||||
`crates/ui-egui/src/menus.rs`.
|
||||
- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the
|
||||
Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id
|
||||
that is unique on screen. Older fields still on `TextEdit` move over as they are touched.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`<scratch>/<agent-name>/`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
|
||||
Generated
+183
-30
@@ -988,6 +988,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -1062,6 +1072,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1202,6 +1221,19 @@ dependencies = [
|
||||
"thousands",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1447,6 +1479,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -2230,6 +2283,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2486,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",
|
||||
]
|
||||
@@ -2588,6 +2650,25 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2612,6 +2693,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jixel"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -2909,6 +2996,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -2976,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2987,18 +3084,26 @@ dependencies = [
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-raw",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3009,10 +3114,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-mcp",
|
||||
"lightcraft-merge",
|
||||
@@ -3024,11 +3130,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jixel",
|
||||
"jpeg-decoder",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3039,6 +3147,7 @@ dependencies = [
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
"proptest",
|
||||
"rav1e",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"serde",
|
||||
@@ -3050,7 +3159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -3059,7 +3168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"faer-core",
|
||||
"lightcraft-denoise-core",
|
||||
@@ -3076,7 +3185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-denoise-core"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3091,7 +3200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -3103,10 +3212,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
@@ -3116,8 +3226,11 @@ dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-geocode",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-lensdb",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
@@ -3139,7 +3252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-faces"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
@@ -3153,7 +3266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-fetch"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rustls",
|
||||
@@ -3162,9 +3275,17 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geocode"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3172,7 +3293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -3181,6 +3302,7 @@ dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
@@ -3189,14 +3311,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-inpaint"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-lensdb"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"quick-xml 0.38.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -3207,7 +3346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3223,7 +3362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -3235,12 +3374,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-inpaint",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
@@ -3252,7 +3392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -3264,7 +3404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -3276,7 +3416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
@@ -3298,7 +3438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -3309,7 +3449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-segment"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -3322,11 +3462,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3335,9 +3475,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -3346,6 +3488,7 @@ dependencies = [
|
||||
"lightcraft-engine",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-preview",
|
||||
@@ -3359,7 +3502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
@@ -4162,6 +4305,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -6819,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+16
-1
@@ -3,13 +3,14 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
@@ -23,6 +24,8 @@ lightcraft-raw = { path = "crates/raw" }
|
||||
lightcraft-codecs = { path = "crates/codecs" }
|
||||
lightcraft-meta = { path = "crates/meta" }
|
||||
lightcraft-develop = { path = "crates/develop" }
|
||||
lightcraft-lensdb = { path = "crates/lensdb" }
|
||||
lightcraft-inpaint = { path = "crates/inpaint" }
|
||||
lightcraft-scenes = { path = "crates/scenes" }
|
||||
lightcraft-pipeline = { path = "crates/pipeline" }
|
||||
lightcraft-gpu = { path = "crates/gpu" }
|
||||
@@ -35,6 +38,7 @@ lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
lightcraft-geocode = { path = "crates/geocode" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -66,8 +70,12 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
jixel = "=0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
|
||||
rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
|
||||
avif-serialize = "0.8.6"
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
|
||||
@@ -84,6 +92,13 @@ eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
|
||||
# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same)
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] }
|
||||
# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸
|
||||
# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it.
|
||||
zbus = "=5.19.0"
|
||||
web-time = "1"
|
||||
|
||||
# web (apps/lightcraft-web)
|
||||
|
||||
@@ -15,6 +15,19 @@ list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
|
||||
|
||||
crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the
|
||||
camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data
|
||||
(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software
|
||||
Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching
|
||||
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
|
||||
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
|
||||
|
||||
crates/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane
|
||||
fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and
|
||||
poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache
|
||||
License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each
|
||||
ported file says so.
|
||||
|
||||
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
|
||||
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
|
||||
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
|
||||
@@ -41,3 +54,10 @@ also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/mai
|
||||
(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ
|
||||
UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's
|
||||
licence as OFL-<family>.txt.
|
||||
|
||||
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
|
||||
are never distributed with it. On the user's request (Photo > Download Place Names) the app
|
||||
downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames
|
||||
(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed
|
||||
under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/);
|
||||
attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own.
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-hans.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
|
||||
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
|
||||
@@ -249,6 +254,20 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
|
||||
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
|
||||
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
|
||||
- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
|
||||
rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
|
||||
current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
|
||||
supports cancellation and protects originals and XMP sidecars. From the CLI:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
|
||||
```
|
||||
|
||||
`export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
|
||||
output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
|
||||
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
|
||||
This is a contact-sheet workflow,
|
||||
not a printer-driver or custom picture-package layout editor.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
|
||||
@@ -269,7 +288,8 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
- **GPU-accelerated, CPU-exact.** The whole develop pipeline runs as wgpu compute kernels (Metal / Vulkan / DX12),
|
||||
checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in
|
||||
~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode.
|
||||
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects.
|
||||
Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. Batch exports
|
||||
render several photos side by side, so one's decode and encode overlap another's render.
|
||||
- **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the
|
||||
full render follows (~0.2–0.5 s for 24 MP). The next and previous photos are prepared in the background, so stepping
|
||||
through a shoot takes ~50 ms per photo.
|
||||
@@ -282,18 +302,21 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md)):
|
||||
|
||||
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity.md](docs/parity.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
|
||||
older-Canon raws on one machine.
|
||||
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **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;
|
||||
- **HDR, video and the Classic Print / Book / Map modules.**
|
||||
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
|
||||
- **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).
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
@@ -303,12 +326,13 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
|
||||
| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
|
||||
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
|
||||
| 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 | ✅ |
|
||||
@@ -316,7 +340,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| 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) ⬜ |
|
||||
| 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; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
|
||||
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
|
||||
|
||||
<sub>✅ works today · 🚧 in progress · ⬜ not started</sub>
|
||||
@@ -325,6 +349,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
|
||||
## Quick start
|
||||
|
||||
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
|
||||
@@ -352,9 +378,9 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
```
|
||||
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
@@ -383,6 +409,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
|
||||
@@ -444,7 +473,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
|
||||
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
|
||||
default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
|
||||
option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -463,6 +494,11 @@ Installers and executables are code-signed.
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V (riscv64): `lightcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39 or newer, e.g. Ubuntu 24.04+).
|
||||
|
||||
On a Wayland desktop LightCraft runs through XWayland when it is available, because only X11 gives
|
||||
windows file drag-and-drop; set `LIGHTCRAFT_WAYLAND=1` to use native Wayland (sharper fractional scaling, no drop).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -549,3 +585,5 @@ Forks and modified versions must remove them.
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
|
||||
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
@@ -0,0 +1,567 @@
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
|
||||
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<h1 align="center">LightCraft</h1>
|
||||
|
||||
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-hans.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br>
|
||||
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
|
||||
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
|
||||
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">ArtCraft</a> ·
|
||||
<a href="https://getartcraft.com/apps">全部 Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的编辑视图:放大视图中是安塞尔·亚当斯的《特顿山脉与蛇河》,右侧打开着 Light 与 Effects 面板,底部的胶片窗格中是四张展示照片" width="100%">
|
||||
<br>
|
||||
<sub><i>安塞尔·亚当斯,《特顿山脉与蛇河》(1942)。公有领域,美国国家档案馆。在 LightCraft 中显影。</i></sub>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **ArtCraft 是一个由形形色色的创作者组成的社区。** 数字艺术、生成艺术、音乐、游戏 —
|
||||
> 只要你在创作,你就是我们中的一员。**[来 Discord 打个招呼吧](https://discord.gg/artcraft)。**
|
||||
|
||||
<p align="center">
|
||||
<a href="#edit-like-you-mean-it">编辑</a> ·
|
||||
<a href="#color-grading-the-cinematic-way">颜色分级</a> ·
|
||||
<a href="#before--after">修改前后对比</a> ·
|
||||
<a href="#masking-that-goes-where-you-point">蒙版</a> ·
|
||||
<a href="#presets-profiles--the-color-mixer">预设</a> ·
|
||||
<a href="#organize-everything">图库</a> ·
|
||||
<a href="#built-for-agents">智能体与 MCP</a> ·
|
||||
<a href="#fast-native-private">性能</a> ·
|
||||
<a href="#feature-status">功能状态</a> ·
|
||||
<a href="#quick-start">快速开始</a> ·
|
||||
<a href="ROADMAP.md">路线图</a> ·
|
||||
<a href="#downloads">下载</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Edit like you mean it
|
||||
|
||||
LightCraft 是一个装在单个原生应用里的完整暗房。每一项调整都是**非破坏性**的,原始文件永远不会被改动。
|
||||
每个滑块都经过一条**场景参考、宽色域、32 位浮点的管线**渲染:高光像胶片一样柔和滚降,阴影被提亮却不产生光晕,
|
||||
色彩从拍摄到导出始终保持干净。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ☀️ Light
|
||||
**曝光(Exposure)、对比度、高光、阴影、白色色阶、黑色色阶**,配合边缘感知的局部色调映射(对对数亮度做引导滤波)。
|
||||
把高光拉到 −100,就能救回一片过曝的天空,而且不会出现用简单曲线时会有的灰色光晕。
|
||||
|
||||
### 🎨 Color
|
||||
按**色温**和**色调**调整**白平衡**(raw 用开尔文,JPEG 用相对值),带预设、自动,以及点击即可中和的**吸管**。
|
||||
保护肤色的**鲜艳度**、**饱和度**、8 个色段的**颜色混合器**(色相/饱和度/亮度),以及带混合与平衡的
|
||||
三路**颜色分级**色轮。所有计算都在 OkLCh 这个现代感知色彩空间里完成。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ✨ Effects
|
||||
让细节更细腻的**纹理(Texture)**、让中间调更有力的**清晰度**、**去朦胧**(暗通道先验加引导滤波细化;
|
||||
推到负值可以增加空气感)、裁剪后带高光优先、圆度和羽化的**暗角**,以及分辨率无关、可调大小与粗糙度的
|
||||
胶片**颗粒**。
|
||||
|
||||
### 📈 Tone Curve
|
||||
带可移动分割点的参数化区域曲线,**外加** RGB、红、绿、蓝的点曲线。曲线在构造上就是单调的,
|
||||
所以不会意外出现影调反转。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/curve-tetons.jpg" alt="Light 面板下方打开的色调曲线,RGB 点曲线上是一条平缓的 S 形曲线,作用于《特顿山脉与蛇河》" width="100%">
|
||||
<br>
|
||||
<sub>RGB 点曲线上一条平缓的 S 形曲线,就在 Light 滑块正下方。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Color grading, the cinematic way
|
||||
|
||||
用随处拖动的色轮,分别给阴影、中间调和高光做分离色调。下图中,多萝西娅·兰格的
|
||||
*《移民母亲》* 只拖动两次就获得了温暖的、类似照片冲印的色调(高光 42°,阴影 28°)。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/grading-migrant-mother.jpg" alt="中间调、阴影和高光的颜色分级色轮,旁边是多萝西娅·兰格的《移民母亲》,被调成温暖的冲印色调" width="100%">
|
||||
<br>
|
||||
<sub>Color 面板中的阴影、中间调和高光色轮。<i>多萝西娅·兰格,《移民母亲》(1936)。公有领域,美国国会图书馆。</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Before & after
|
||||
|
||||
按住 <kbd>\\</kbd> 偷看原图,按 <kbd>Y</kbd> 并排对比,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分屏查看。
|
||||
下面每一张都是 LightCraft 的真实截图,由智能体通过[控制通道](#built-for-agents)自动截取。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《特顿山脉与蛇河》的并排修改前后对比:修改后的云层更深,河岸边的阴影更开阔"><br><sub><b>《特顿山脉与蛇河》。</b> 高光 −45、阴影 +38、清晰度 +28、去朦胧 +18。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母亲》的并排修改前后对比:修改后更温暖,阴影被提亮"><br><sub><b>《移民母亲》。</b> 阴影 +42、纹理 +18、分离色调、暗角。<i>多萝西娅·兰格,1936(公有领域)。</i></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的并排修改前后对比:月球地平线上方是地球,修改后略暖、更浓郁"><br><sub><b>《地出》。</b> 去朦胧 +22、高光 −30、更暖的白平衡、鲜艳度 +22。<i>NASA/比尔·安德斯,阿波罗 8 号,1968(公有领域)。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《蓝色弹珠》的并排修改前后对比:修改后黑色更深,云层细节更结实"><br><sub><b>《蓝色弹珠》。</b> 高光 −38、黑色色阶 −20、去朦胧 +15、鲜艳度 +30。<i>NASA,阿波罗 17 号,1972(公有领域)。</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Masking that goes where you point
|
||||
|
||||
用**画笔**涂抹(大小、羽化、流量、密度、擦除),放置带可拖动控制点的**线性渐变**和**径向渐变**,
|
||||
或者按**亮度范围**、**颜色范围**、**天空**、**主体**和**背景**来选择。用**添加/减去/相交**组合各个组件,
|
||||
任意反相,每个蒙版还能调 15 项局部调整(色温、色调、曝光、对比度、高光、阴影、白色色阶、黑色色阶、纹理、
|
||||
清晰度、去朦胧、色相、饱和度、锐化、降噪)以及一个总体的数量。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/masking.jpg" alt="蒙版面板中有一个线性天空蒙版和一个径向阳光光晕蒙版;径向渐变在湖景照片上以红色叠加层画在太阳周围" width="100%">
|
||||
<br>
|
||||
<sub>叠加在线性「天空」蒙版之上的径向「阳光光晕」蒙版(色温 +40、曝光 +0.50)。<i>照片来自 LightCraft 程序生成的演示图库。</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Presets, profiles & the Color Mixer
|
||||
|
||||
内置十八个手工制作的预设(*Golden Hour、Teal & Orange、Faded Matte、Selenium Tone、Crisp
|
||||
Landscape* 等等),每个都有 0 到 200 % 的**数量**滑块。你可以从任意一组设置保存自己的预设、标记收藏,
|
||||
并在整个选区之间只复制、粘贴或同步你选定的那些分组。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/presets-mixer.jpg" alt="预设列按 B&W、Color、Film、Landscape、Portrait 和 Style 分组,旁边是 Color 面板,8 色段颜色混合器打开在紫色上" width="100%">
|
||||
<br>
|
||||
<sub>预设列与 8 色段颜色混合器并排。<i>照片来自演示图库。</i></sub>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ✂️ Crop, straighten & geometry
|
||||
自由或锁定的长宽比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖动旋转拉直并始终把最大的裁剪框
|
||||
保持在画面内,三分线/网格/黄金比例叠加,翻转和 90° 旋转,外加手动垂直、水平、旋转、长宽比、
|
||||
缩放和位移变换。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚫️ Black & White
|
||||
一键(<kbd>V</kbd>)转为单色,配合 8 色段**黑白混合**压暗天空或让树叶发亮。
|
||||
之后再用颜色分级调出硒调、棕褐或分离色调的印相效果。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《特顿山脉与蛇河》上的裁剪工具:一个略微旋转的 16:9 裁剪框,带三分线叠加,右侧是几何滑块"><br><sub>一个 16:9 的裁剪框拉直了 +2.5°,带三分线叠加。<i>安塞尔·亚当斯,1942(公有领域)。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的分屏修改前后视图:左边是彩色,右边是带颗粒和暗角的黑白"><br><sub>分屏视图:左边是彩色原图,右边是带清晰度、暗角和颗粒的黑白。<i>演示图库。</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Organize everything
|
||||
|
||||
一个不碍事的图库:**所有照片**、**最近添加**、**留用**、**按日期**、嵌套在**文件夹**里的**相册**,
|
||||
以及**最近删除**。用 <kbd>0</kbd>–<kbd>5</kbd> 评星,用 <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd> 加旗标,用 <kbd>6</kbd>–<kbd>9</kbd> 加颜色标签。搜索支持字段:
|
||||
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每个视图都能按拍摄日期、
|
||||
导入日期、编辑日期、名称、星级、大小或随机排序(一种稳定的乱序;「视图 → 排序 → 重新乱序」可换一个顺序)。
|
||||
两端对齐的**照片网格**和**正方形网格**视图都做了虚拟化,
|
||||
所以无论是四十张还是四万张照片都同样顺滑。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 张演示照片的两端对齐照片网格,带星级和旗标,侧边栏是 Travel 2026 文件夹下嵌套的相册"><br><sub>照片网格,相册嵌套在文件夹里,带星级和旗标。<i>演示图库。</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="正方形网格,显示四张公有领域展示照片及其星级和留用旗标"><br><sub>四张公有领域展示照片的正方形网格。</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的信息面板,显示文件名、2400 × 2400 的 JPEG 尺寸、星级、标题字段和相机元数据"><br><sub>信息面板:文件、尺寸、星级、标题、题注、版权和相机元数据。<i>NASA/比尔·安德斯,《地出》,阿波罗 8 号,1968(公有领域)。</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Built for agents
|
||||
|
||||
LightCraft 里的每一个菜单项、滑块、画笔笔触、裁剪手柄和按键都是一个**命令**,拥有稳定的 ID 和
|
||||
JSON 参数。界面、键盘、CLI、JSON-lines 控制通道和一个 **MCP 服务器**全都通过同一个入口分发。
|
||||
智能体可以挑片、显影、给天空加蒙版并导出,而且能*看到*结果。
|
||||
|
||||
```sh
|
||||
lightcraft --control 7980 ~/Pictures/trip
|
||||
```
|
||||
```jsonc
|
||||
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
|
||||
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
|
||||
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
|
||||
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 按名称操作任意控件
|
||||
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
|
||||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、
|
||||
默认值和当前值。
|
||||
- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端),
|
||||
同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着
|
||||
到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。
|
||||
|
||||
```sh
|
||||
cargo build --release -p lightcraft-cli
|
||||
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 无界面
|
||||
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 驱动运行中的应用
|
||||
```
|
||||
- **可脚本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
|
||||
path=out.jpg longEdge=2048` 可以运行任意命令链(无界面、在已保存的图库上,或对正在运行的应用),
|
||||
每条命令打印一行 JSON 结果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
|
||||
- **一切皆可撤销**,包括智能体的操作:一次滑块拖动(或脚本里一连串更新)算一步撤销。
|
||||
- **联系表(原生应用/CLI):** 选中照片,然后 **文件 → 联系表 PDF…**。可选择 A4 或 Letter、横向、
|
||||
行数和列数以及文件名题注;保存分页 PDF,再用你的 PDF 阅读器打印。导出使用当前编辑,每张照片完整放入
|
||||
而不再次裁剪,以 150 dpi 和 sRGB 渲染。后台导出支持取消,并保护原文件和 XMP 附属文件。从 CLI 调用:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
|
||||
```
|
||||
|
||||
`export.contactSheet` 还接受显式的 `ids`、`landscape=true` 和 `paper=letter`。只有当整个 PDF 生成成功后,
|
||||
它才会替换已有的输出文件。限制:1000 张照片、100 页、256 MiB;题注使用与水印相同的字体覆盖来栅格化
|
||||
(中日韩文字需要 craft-fonts),过长的名称会在中间省略。
|
||||
这是一套联系表工作流,
|
||||
不是打印驱动或自定义拼版编辑器。
|
||||
- **每个控件都可寻址**(`ui.widgets`)且可按名称点击,所以智能体操作的是真实界面,而不是绕到侧门。
|
||||
- 本 README 中的截图全部由 [`docs/showcase/`](docs/showcase/) 脚本端到端生成。
|
||||
协议参考:[docs/control-protocol.md](docs/control-protocol.md)。
|
||||
|
||||
<br>
|
||||
|
||||
## Fast, native, private
|
||||
|
||||
- **纯 Rust,无 C。** 我们自己的 RAW 解码器(DNG、佳能 CR2/CR3、索尼 ARW、尼康 NEF、富士 RAF 含 X-Trans、
|
||||
松下 RW2/徕卡 RWL、宾得 PEF、奥林巴斯 ORF)、我们自己的色彩科学、我们自己的管线。JPEG、PNG、TIFF、WebP、
|
||||
PSD 合成图和 JPEG XL 现在就能打开;加 `--features heif` 还能打开 HEIC/HEIF 照片(HEVC 是构建时的选择,
|
||||
与 PhotoCraft 中一致)。
|
||||
- **场景参考与宽色域。** 内部使用线性 Rec.2020 浮点、Bradford 适应的白平衡、用色域映射代替裁剪,
|
||||
raw 走胶片式肩部,未改动过的 JPEG 则像素级直通。
|
||||
- **分辨率无关的编辑。** 半径和画笔大小都相对于图像,所以 400 px 的预览、你的 5K 显示器和 60 MP 的导出
|
||||
看起来完全一致。
|
||||
- **GPU 加速,与 CPU 一致。** 整条显影管线都作为 wgpu 计算内核运行(Metal/Vulkan/DX12),
|
||||
并与 CPU 管线比对到 1/255 以内。在 24 MP raw 上(Apple M4 Pro):一次滑块更新约 4 ms 重新渲染,
|
||||
冷启动 2.5 MP 放大视图约 30 ms,全尺寸导出约 0.3 s(含并行 JPEG 编码)。
|
||||
没有 GPU 时同一条管线在所有 CPU 核心上运行,只重做受滑块影响的那些阶段。批量导出会并排渲染多张照片,
|
||||
所以一张的解码和编码能与另一张的渲染重叠。
|
||||
- **即时挑片。** 打开一张 raw 会在约 0.1 s 内显示其内嵌的相机预览或缓存渲染,完整渲染随后跟上
|
||||
(24 MP 约 0.2–0.5 s)。上一张和下一张会在后台准备好,因此翻阅一次拍摄时每张约 50 ms。
|
||||
- **后台渲染。** 一个工作线程池在 UI 线程之外渲染放大视图、修改前后对比和每一个可见缩略图:
|
||||
拖动时用草稿,松手后出全质量。
|
||||
- **本地优先。** 没有账号、没有云、没有遥测、没有订阅。你的目录是一份只追加的可读操作日志,
|
||||
可以 diff、备份或重放。
|
||||
|
||||
<br>
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
|
||||
[docs/parity.md](docs/parity.md)。
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。
|
||||
|
||||
| 领域 | 状态 |
|
||||
|---|---|
|
||||
| 图库:相册、文件夹、智能相册、堆叠(含自动堆叠)、虚拟副本、星级、旗标、标签、过滤器栏、搜索、排序、网格、胶片窗格 | ✅ |
|
||||
| 挑片:比较(同步缩放)和筛选视图、自动前进、即时预览 | ✅ |
|
||||
| Light、Color、Effects(暗角样式)、色调曲线(+ 细化饱和度、目标调整)、颜色混合器(+ 目标调整)、点颜色、颜色分级、校准、黑白 | ✅ |
|
||||
| 蒙版:画笔、线性、径向、亮度/颜色范围、添加/减去/相交 | ✅(AI 主体/天空目前使用经典启发式算法) |
|
||||
| 裁剪、拉直工具 + 自动拉直、翻转、旋转、长宽比、叠加参考线 | ✅ |
|
||||
| 配置文件(Color、Neutral、Vivid、Landscape、Portrait、Monochrome:我们自己的风格)、预设、版本、历史记录、复制/粘贴/同步设置 | ✅ |
|
||||
| 相机色彩:DNG 文件使用其自带矩阵 | ✅ DNG · 🟡 我们自己的索尼/富士配置文件;缺少实测校准数据库 |
|
||||
| 原生 macOS 菜单栏(由命令注册表生成)、控制通道 + 每个控件可寻址、无界面 UI 快照 | ✅ |
|
||||
| RAW:DNG、CR2、CR3(无损 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未压缩 + 无损/有损压缩)、富士 RAF(未压缩 + 无损/有损压缩,Bayer + X-Trans)、松下 RW2/徕卡 RWL/Panasonic RAW(所有 raw 格式,从 DMC-LX1 到 DC-S1RM2)、宾得 PEF、奥林巴斯 ORF(未压缩)、三星 SRW(未压缩);所有格式的嵌入预览,含 CR3 | 🟡 · CR3、压缩 ORF 和压缩 SRW 解码 ⬜ |
|
||||
| 细节:锐化、亮度 + 色彩降噪 | ✅ · AI 降噪、超分辨率 ⬜ |
|
||||
| 污点去除/修复/仿制(自动来源)、可视化斑点、红眼和宠物眼(自动瞳孔检测、眼神光) | ✅ · 内容识别填充、污点图钉编辑 🚧 |
|
||||
| 导出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸调整、文件大小上限、输出锐化、命名模板、批处理、元数据策略、文字或图像水印 | ✅ |
|
||||
| HDR:带余量上限的 HDR 编辑、SDR 呈现、可视化 HDR;导出为 ISO 21496-1 增益图 JPEG、PQ AVIF 或 32 位浮点 TIFF | ✅ · HDR 显示 ⬜ |
|
||||
| 图库持久化(防崩溃的操作日志 + 快照、后台压缩、保存失败会上报)、磁盘缩略图缓存 | ✅ |
|
||||
| 导入:在原位置添加/复制/移动、重命名与文件夹模板、设备、重复检测、监视文件夹;本地文件夹浏览 | ✅ |
|
||||
| MCP 服务器(无界面或运行中的应用、持久化图库)、CLI、控制通道 | ✅ |
|
||||
| XMP 附属文件(读取/写入、自动写入)、读取 `crs:` 显影设置、预设文件(`.lcpreset`、XMP 预设) | ✅ |
|
||||
| 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ |
|
||||
| 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ |
|
||||
| GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 |
|
||||
| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) |
|
||||
| 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 |
|
||||
|
||||
<sub>✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始</sub>
|
||||
|
||||
<br>
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # 打开你的图库(~/Pictures/LightCraft Library;新图库会先带演示照片)
|
||||
cargo run --release -p lightcraft -- ~/Pictures/trip # 导入你的照片(扫描文件夹,跳过重复项)
|
||||
cargo run --release -p lightcraft -- --memory # 用完即弃的内存演示会话(不写入任何内容)
|
||||
cargo run --release -p lightcraft -- --control 7980 # 带自动化通道
|
||||
cargo xtask web --serve # 在浏览器中运行同一个应用:http://127.0.0.1:8080/
|
||||
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
|
||||
cargo xtask ci # fmt、clippy、测试、分层、wasm 检查
|
||||
```
|
||||
|
||||
CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一个构建任务
|
||||
(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建,
|
||||
而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的
|
||||
完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置
|
||||
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
|
||||
日常开发命令保持它们各自的设置。
|
||||
|
||||
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
```
|
||||
|
||||
不指定它时,LightCraft 构建和运行的方式完全相同,只是中文和日文文字没有字形。字体从不提交到
|
||||
本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。
|
||||
|
||||
**界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 →
|
||||
语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。
|
||||
|
||||
**日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log`
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),从不写进图库。
|
||||
从桌面菜单或 Dock 启动没有终端,所以请把这个文件附在错误报告里(**帮助 ▸ 打开日志
|
||||
文件夹** 会在文件管理器中显示它)。每次启动会把
|
||||
上一个日志移到 `lightcraft.1.log`,再上一个移到 `lightcraft.2.log`,这样崩溃那次的日志能在下次启动后
|
||||
保留下来;文件到 16 MiB 停止增长,`--version` 和 `--help` 不写日志,带
|
||||
`LIGHTCRAFT_NO_PREFS` 运行时只写到标准错误。默认情况下 LightCraft 自己的 crate 记到 `info`,其他都记到
|
||||
`warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行为和以前一样(LightCraft 自己的 crate 用该级别,其余只记警告和
|
||||
错误;其他值:只记警告和错误),并且优先于 `RUST_LOG`,后者会把默认值替换为
|
||||
env_logger 风格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
|
||||
`*` 结尾的指令覆盖所有以它开头的目标,如 `lightcraft*=debug`)。panic 也会记录在那里,
|
||||
并且仍然写在临时文件夹里的 `lightcraft-panics.log`。日志器是 `apps/lightcraft/src/logging.rs`。
|
||||
|
||||
网页版需要 `wasm32-unknown-unknown` target 和匹配的 `wasm-bindgen` CLI
|
||||
(`cargo xtask web` 会打印确切的安装命令);见 [docs/web.md](docs/web.md)。
|
||||
|
||||
**Nix** 会构建桌面应用和 `lightcraft-cli`(Nix 构建总是包含 craft-fonts 输入,所以日文
|
||||
文字有字形):
|
||||
|
||||
```sh
|
||||
nix run github:storytold/lightcraft # 桌面应用
|
||||
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
|
||||
nix develop github:storytold/lightcraft # rust 工具链 + 原生依赖 + 字体
|
||||
```
|
||||
|
||||
在 flake 配置中(NixOS、home-manager、nix-darwin):
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.lightcraft.url = "github:storytold/lightcraft";
|
||||
# 可选:使用你自己的 nixpkgs,而不是 LightCraft 锁定的那个
|
||||
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# 然后在 NixOS 或 home-manager 模块中(`inputs` 在作用域内):
|
||||
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 让 `pkgs.lightcraft` 可用
|
||||
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
|
||||
```
|
||||
|
||||
`nix build` 会安装与 .deb/.rpm 相同的 desktop 文件、hicolor 图标和 AppStream 元数据,并运行
|
||||
`cargo test --workspace` 作为它的检查阶段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳过)。
|
||||
|
||||
**键盘:** <kbd>G</kbd> 网格 · <kbd>D</kbd> 细节 · <kbd>E</kbd> 编辑 · <kbd>C</kbd> 裁剪 · <kbd>M</kbd> 蒙版 ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> 预设 · <kbd>\\</kbd> 原图 · <kbd>Y</kbd> 修改前后 · <kbd>Z</kbd> 缩放 ·
|
||||
<kbd>J</kbd> 溢出警告 · <kbd>⌘Z</kbd> 撤销 · <kbd>⌘/</kbd> 全部快捷键。
|
||||
|
||||
## How it's built
|
||||
|
||||
一个引擎优先的 Cargo 工作区,由小而经过测试的 crate 组成,并强制分层(`cargo xtask layers`):`geom`、
|
||||
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
|
||||
egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 的存在。`mcp` 和应用(`lightcraft`、
|
||||
`lightcraft-cli`)坐落在 `engine` 之上。
|
||||
|
||||
## Contributing
|
||||
|
||||
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
|
||||
|
||||
- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
|
||||
只依据公开规范和黑盒观察来工作。
|
||||
- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
|
||||
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
|
||||
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
|
||||
- **纯 Rust**、强制 crate 分层、一切皆命令,并且每次提交前 `cargo xtask ci` 必须通过
|
||||
(一个提交一个任务 ID)。
|
||||
- **绝不崩溃。** 非测试代码返回错误而不是 panic:不用 `unwrap()`、`expect()`、`panic!` 或
|
||||
`unsafe`,对任何来自输入的数据都做受检索引,并且每个崩溃修复都带一个回归测试。细节见
|
||||
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
|
||||
|
||||
有问题、想法,或者想先讨论一个 bug?来 [Discord](https://discord.gg/artcraft)。
|
||||
|
||||
<br>
|
||||
|
||||
## Downloads
|
||||
|
||||
**第一次用 LightCraft?** 从 [getartcraft.com 的 LightCraft 页面](https://getartcraft.com/apps/lightcraft)下载。那是最简单的安装方式。
|
||||
|
||||
**想要特定的构建或格式?** 在 GitHub 上,[最新发布](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有构建,[全部发布](https://github.com/storytold/lightcraft/releases)则有更早的版本及其说明。文件名中的 `<ver>` 是版本号,`SHA256SUMS.txt` 列出了每个文件的校验和。
|
||||
|
||||
### Windows
|
||||
|
||||
| 构建 | 安装程序 | 便携版 |
|
||||
|---|---|---|
|
||||
| x64(64 位 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64(骁龙及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86(32 位) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认
|
||||
`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装,
|
||||
并可选择启动它。无人值守安装:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
|
||||
|
||||
### macOS
|
||||
|
||||
| 构建 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| 应用,通用(Apple 芯片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已签名并公证 |
|
||||
| 命令行工具,通用 | `lightcraft-cli-<ver>-macos-universal.zip` | 已签名并公证 |
|
||||
|
||||
### Linux
|
||||
|
||||
| 格式 | x86_64 | aarch64 (ARM64) | 说明 |
|
||||
|---|---|---|---|
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 随处可运行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 文件)自我更新 |
|
||||
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| 压缩包 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解压到任意位置 |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| 构建 | 文件 |
|
||||
|---|---|
|
||||
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### 网页版(WebAssembly)
|
||||
|
||||
| 构建 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| 静态站点 | `lightcraft-web-<ver>.zip` | 在现代浏览器中运行;托管到任意静态服务器 |
|
||||
|
||||
<br>
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
LightCraft 是 **Crafting Apps** 之一:来自 [ArtCraft](https://getartcraft.com/) 团队的免费开源创作工具,
|
||||
每一个都用 Rust 从零编写,每一个都能独立使用。
|
||||
|
||||
| | 应用 | 用途 | 代码 | 了解更多 |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 图像编辑:图层、蒙版、文字和真正的 PSD 文件 | [GitHub](https://github.com/storytold/photocraft) | [网站](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 矢量插画 | [GitHub](https://github.com/storytold/vectorcraft) | [网站](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 视频剪辑、调色与声音 | [GitHub](https://github.com/storytold/filmcraft) | [网站](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片图库与 raw 显影 · 你在这里** | [GitHub](https://github.com/storytold/lightcraft) | [网站](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 阅读、整理和保护 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [网站](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 动态图形与视觉特效 | [GitHub](https://github.com/storytold/effectcraft) | [网站](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 页面排版与出版 | [GitHub](https://github.com/storytold/designcraft) | [网站](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
以及 [**ArtCraft**](https://getartcraft.com/) 本身,我们为想要真正掌控力的创作者打造的 AI 图像与视频工作室。
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">Come make things with us</h3>
|
||||
|
||||
<p align="center">
|
||||
我们的 Discord 是各种创作者聚集的地方:画画的人、拍照的人、做动画的人、剪片子的人、
|
||||
排版的人,还有仍在摸索自己喜欢做什么的人。分享你在做的东西,
|
||||
寻求帮助,告诉我们哪里坏了,或者告诉我们你希望这些工具能做什么。
|
||||
无论你用什么媒介,无论你做了多久,这里都欢迎你。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">getartcraft.com</a> ·
|
||||
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
|
||||
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft 采用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 双许可,任你选择。
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. 必需的声明见 [NOTICE](NOTICE)。
|
||||
|
||||
随附的字体、图标、图像和其他资源保留各自的开放许可;每一项都在
|
||||
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、来源和许可。
|
||||
|
||||
展示照片是经 Wikimedia Commons 使用的公有领域作品:安塞尔·亚当斯,*《特顿山脉与蛇河》*
|
||||
(1942,美国国家档案馆);多萝西娅·兰格,*《移民母亲》*(1936,美国国会图书馆);比尔·安德斯/NASA,
|
||||
*《地出》*(1968);NASA,*《蓝色弹珠》*(1972)。演示图库由 LightCraft 程序生成。界面字体:
|
||||
Inter(SIL OFL 1.1)。用 [craft-fonts](https://github.com/storytold/craft-fonts) 构建的版本(所有正式发布版)
|
||||
还会内嵌它的中文和日文字体(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列在其
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有图标均为原创。
|
||||
|
||||
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名称、字标和标志是
|
||||
ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改的情况下,作为
|
||||
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
|
||||
分支和修改版本必须移除它们。
|
||||
|
||||
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
+147
-207
@@ -1,235 +1,175 @@
|
||||
# LightCraft roadmap
|
||||
|
||||
Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock
|
||||
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
|
||||
projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land.
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
## Where we stand
|
||||
> **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
|
||||
|
||||
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
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
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md).
|
||||
|
||||
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
## Headline numbers
|
||||
|
||||
Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against
|
||||
Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85,
|
||||
duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| User | Readiness | What blocks them |
|
||||
| | Value | Kind |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
|
||||
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
|
||||
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open |
|
||||
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (weighted by use) | **~77%** | estimated |
|
||||
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~49%** (44–54%) | estimated |
|
||||
| **Essentials user** | **~64%** (59–69%) | estimated |
|
||||
| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
|
||||
| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
|
||||
|
||||
## Where we're going
|
||||
Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
|
||||
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---:|---:|---|
|
||||
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
|
||||
| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
|
||||
| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
|
||||
Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); then ORF, then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
|
||||
checked for plausible colour; fix per-model bugs (#85).
|
||||
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
|
||||
(references stay in the local `plan/`), then tune against the numbers.
|
||||
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
|
||||
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
|
||||
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
|
||||
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
|
||||
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
|
||||
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
|
||||
and accessibility.
|
||||
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
|
||||
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
|
||||
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
|
||||
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
|
||||
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
|
||||
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
|
||||
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
|
||||
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
|
||||
Hours are calibrated on this repo's PR history; see
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated).
|
||||
|
||||
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
|
||||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
## By dimension
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
| Dimension | Ready | Breadth | Remaining | Doc |
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
|
||||
| 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) |
|
||||
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) |
|
||||
|
||||
## Milestones
|
||||
## Features
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
|
||||
| # | Milestone | Scope (summary) | Estimate (h) | Status |
|
||||
|---|---|---|---|---|
|
||||
| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ |
|
||||
| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) |
|
||||
| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) |
|
||||
| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) |
|
||||
| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ |
|
||||
| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) |
|
||||
| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) |
|
||||
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
|
||||
|
||||
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
|
||||
parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
| Area | Ready | Breadth | Remaining |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.2%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.6%** | 149 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
|
||||
| **All in-scope rows** | **79.7%** | 509 | 75.6% |
|
||||
| Import and library | 80% | 90% | 25–45 h |
|
||||
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
|
||||
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
|
||||
| Global develop adjustments | 70% | 93% | 30–60 h |
|
||||
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
|
||||
| Masking | 45% | 67% | 60–110 h |
|
||||
| Remove, heal, Enhance | 30% | 45% | 70–130 h |
|
||||
| Presets, profiles, versions, sync | 85% | 95% | 8–15 h |
|
||||
| Export | 75% | 89% | 15–30 h |
|
||||
| Merge and HDR | 60% | 85% | 20–35 h |
|
||||
| Views, compare, zoom, culling | 70% | 90% | 15–30 h |
|
||||
| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h |
|
||||
| Video | 0% | 0% | 25–45 h |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features).
|
||||
|
||||
✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out.
|
||||
## Languages
|
||||
|
||||
**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is
|
||||
left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours**
|
||||
(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of
|
||||
UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album
|
||||
rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round
|
||||
trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours):
|
||||
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
|
||||
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
| Language | Code | Translated | Status |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium |
|
||||
| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) |
|
||||
| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low |
|
||||
| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium |
|
||||
| **Total remaining** | | **≈ 270–505** | |
|
||||
| English | en | 100% | full (source) |
|
||||
| Simplified Chinese | zh-hans | 87% | partial |
|
||||
| Spanish | es | 86% | partial |
|
||||
| Hindi | hi | 0% | none (no Devanagari shaping) |
|
||||
| Arabic | ar | 0% | none (no RTL / shaping) |
|
||||
| French | fr | 88% | partial |
|
||||
| Portuguese (Brazil) | pt-br | 85% | partial |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 87% | partial |
|
||||
| German | de | 91% | partial |
|
||||
| Korean | ko | 0% | none (no Hangul face) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic
|
||||
parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h,
|
||||
most of it the raw codecs, lens data, video and HDR).
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
|
||||
|
||||
**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and
|
||||
CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the
|
||||
uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing.
|
||||
## Upcoming
|
||||
|
||||
## Totals
|
||||
Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown):
|
||||
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
|
||||
2. CR3 for every Canon body (25–45 h)
|
||||
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
|
||||
4. Full-resolution zoom (8–15 h)
|
||||
5. AI masks: Subject / Sky / People (40–70 h + model decision)
|
||||
6. Compressed ORF and HE NEF (28–55 h)
|
||||
7. Lens profile database of our own (40–80 h)
|
||||
8. Tests in CI on macOS, Windows and Linux (6–12 h)
|
||||
9. Per-model raw verification (20–35 h)
|
||||
|
||||
| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents |
|
||||
|---|---:|---:|
|
||||
| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h |
|
||||
| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h |
|
||||
## Progress log
|
||||
|
||||
The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took
|
||||
≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones).
|
||||
- 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
|
||||
verified; RAW decoding 76% → 77%; headline numbers unchanged.
|
||||
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
|
||||
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
|
||||
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
|
||||
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
|
||||
- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with
|
||||
the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h);
|
||||
alpha gate passes.
|
||||
- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist
|
||||
81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI
|
||||
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
|
||||
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
|
||||
(#646), Lightroom-like Auto (#628), activity stack.
|
||||
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
|
||||
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
|
||||
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact
|
||||
full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting
|
||||
([cr3.md](docs/cr3.md)).
|
||||
- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays
|
||||
verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4
|
||||
profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34.
|
||||
AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on
|
||||
178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in
|
||||
pure Rust and People view.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import,
|
||||
folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background
|
||||
compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment
|
||||
and tracker rows for camera colour, camera coverage and render fidelity.
|
||||
- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones
|
||||
refreshed.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman),
|
||||
ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0
|
||||
samples.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW;
|
||||
README showcase. ≈ 8 h elapsed.
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging
|
||||
ecosystem) complete.
|
||||
|
||||
## Risks that coding hours alone don't retire
|
||||
## Revision history
|
||||
|
||||
- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical
|
||||
fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own.
|
||||
- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first,
|
||||
then our own calibration; long-tail camera/lens coverage grows over time.
|
||||
- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones
|
||||
published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local
|
||||
Laplacian filters, PatchMatch and HEVC (HEIC).
|
||||
- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY)
|
||||
turns it into measured comparisons against local-only Lightroom references.
|
||||
|
||||
## Raw format coverage and known gaps
|
||||
|
||||
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
|
||||
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
|
||||
|
||||
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
|
||||
lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
|
||||
|
||||
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
|
||||
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Lightroom Classic catalog migration
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
|
||||
- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
|
||||
- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
|
||||
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
|
||||
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
|
||||
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
|
||||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
|
||||
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
|
||||
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
|
||||
- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
|
||||
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
|
||||
characters stay upright in top-to-bottom columns, with newlines starting columns to the
|
||||
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
|
||||
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
|
||||
| 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 |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h |
|
||||
| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md |
|
||||
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
|
||||
| 2026-10-02 | major | Parity estimate and effort table |
|
||||
| 2026-09-30 | major | Created |
|
||||
@@ -15,6 +15,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-mcp = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
@@ -51,17 +52,19 @@ USAGE:
|
||||
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
|
||||
--compact list only the helper tools (every command stays reachable via run_command)
|
||||
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
|
||||
same encoder as the app's Export dialog. Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
--settings FILE merge a partial develop-settings JSON file
|
||||
--preset ID apply a preset (see `commands`/presets.list)
|
||||
--size N long edge in pixels (default: full size, cropped)
|
||||
--quality Q JPEG/AVIF quality 1..100 (default 92)
|
||||
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
|
||||
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
|
||||
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
|
||||
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
|
||||
--opt format=original (copy + XMP sidecar) --opt metadata=none
|
||||
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
|
||||
the file to Apple Photos, into that album)
|
||||
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
|
||||
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
|
||||
Options:
|
||||
@@ -85,6 +88,18 @@ USAGE:
|
||||
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
|
||||
lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
|
||||
lightcraft-cli denoise-serve [OPTIONS]
|
||||
Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=<this host>:7990 and
|
||||
LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings,
|
||||
on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN
|
||||
address (e.g. the Tailscale IP) or firewall the port to it. Options:
|
||||
--model ID an installed model other than the selected one
|
||||
--models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's)
|
||||
--listen ADDR address:port to listen on (default 127.0.0.1:7990)
|
||||
--token-env VAR environment variable holding the shared token, ≥ 16 characters
|
||||
(default LIGHTCRAFT_DENOISE_TOKEN)
|
||||
--run-on WHERE auto (default), gpu or cpu
|
||||
--max-connections N concurrent clients (default 8)
|
||||
lightcraft-cli commands [--json] list every command id with its parameters
|
||||
lightcraft-cli controls [--json] list every develop control id with its range
|
||||
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
@@ -115,6 +130,9 @@ fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
|
||||
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -145,6 +163,7 @@ fn main() -> ExitCode {
|
||||
Some("merge") => merge(&args[1..]),
|
||||
Some("synth-merge") => synth_merge(&args[1..]),
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("denoise-serve") => denoise_serve(&args[1..]),
|
||||
Some("calibrate") => calibrate(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -284,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()),
|
||||
@@ -391,7 +414,7 @@ fn merge(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
let paths = expand_paths(&files);
|
||||
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
@@ -506,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()),
|
||||
@@ -652,7 +675,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let input = input.ok_or("render: missing input file")?;
|
||||
let output = output.ok_or("render: missing -o OUTPUT")?;
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
let abs = expand_paths(std::slice::from_ref(&input));
|
||||
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
|
||||
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
|
||||
@@ -681,6 +704,8 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = s.after_export(&o)?;
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
@@ -695,6 +720,12 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
if let Some(photos) = after.run(&[json!({"path": output})], false) {
|
||||
if let Some(err) = photos.get("error").and_then(Value::as_str) {
|
||||
return Err(format!("render: {output} was written, but {err}"));
|
||||
}
|
||||
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -732,12 +763,12 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_default_face_models();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models(),
|
||||
None => Session::new().with_fs().with_default_denoise_models().with_default_face_models(),
|
||||
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
|
||||
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
|
||||
};
|
||||
// Apply compute policy before imports or the first scripted query can discover an adapter.
|
||||
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
|
||||
@@ -897,3 +928,48 @@ fn controls(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`).
|
||||
#[cfg(feature = "denoise")]
|
||||
fn denoise_serve(args: &[String]) -> Result<(), String> {
|
||||
use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve};
|
||||
use lightcraft_engine::denoise::RunOn;
|
||||
let mut model: Option<String> = None;
|
||||
let mut dir: Option<String> = None;
|
||||
let mut listen = format!("127.0.0.1:{DEFAULT_PORT}");
|
||||
let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string();
|
||||
let mut run_on = RunOn::Auto;
|
||||
let mut max_connections = 8usize;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()),
|
||||
"--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()),
|
||||
"--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(),
|
||||
"--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(),
|
||||
"--run-on" => {
|
||||
let v = take_value(args, &mut i, "--run-on")?;
|
||||
run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?;
|
||||
}
|
||||
"--max-connections" => {
|
||||
max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")?
|
||||
}
|
||||
other => return Err(format!("denoise-serve: unknown option `{other}`")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?;
|
||||
let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?;
|
||||
let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?;
|
||||
eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device);
|
||||
if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") {
|
||||
eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)");
|
||||
}
|
||||
let opts = ServerOptions { token, max_connections, ..Default::default() };
|
||||
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "denoise"))]
|
||||
fn denoise_serve(_: &[String]) -> Result<(), String> {
|
||||
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
|
||||
}
|
||||
@@ -7,6 +7,46 @@ use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
|
||||
|
||||
#[test]
|
||||
fn contact_sheet_exports_pdf_and_protects_originals() {
|
||||
let input = tmp("contact-sheet-input.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("contact-sheet.pdf");
|
||||
let (ok, lines, err) = run_cli(
|
||||
&[
|
||||
"--import",
|
||||
input.to_str().unwrap(),
|
||||
"export.contactSheet",
|
||||
&format!("path={}", out.display()),
|
||||
"paper=letter",
|
||||
"landscape=true",
|
||||
"columns=2",
|
||||
"rows=3",
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(ok, "{err}: {lines:?}");
|
||||
assert_eq!(lines[0]["result"]["pages"], 1);
|
||||
assert_eq!(lines[0]["result"]["photos"], 1);
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-1.4"));
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]"));
|
||||
let before = std::fs::read(&input).unwrap();
|
||||
for target in [input.clone(), input.with_extension("xmp")] {
|
||||
if target != input {
|
||||
std::fs::write(&target, b"existing sidecar").unwrap();
|
||||
}
|
||||
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None);
|
||||
assert!(!ok, "overwrote protected file");
|
||||
}
|
||||
assert_eq!(std::fs::read(&input).unwrap(), before);
|
||||
assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar");
|
||||
let prior = std::fs::read(&out).unwrap();
|
||||
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None);
|
||||
assert!(!ok);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
@@ -236,11 +276,8 @@ fn snapshot_script_failure_exits_non_zero() {
|
||||
#[test]
|
||||
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
|
||||
let script = tmp("snap-ui-zoom.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"})))
|
||||
.unwrap();
|
||||
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
|
||||
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
|
||||
let o = Command::new(BIN)
|
||||
@@ -439,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]
|
||||
@@ -540,3 +599,69 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
assert!(image.exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// `denoise-serve` serves the selected installed model; a client greets it and gets the CPU's answer for a tile
|
||||
/// (within half-float rounding on the wire), and a wrong token or a missing token variable is refused.
|
||||
#[cfg(feature = "denoise")]
|
||||
#[test]
|
||||
fn denoise_serve_runs_tiles_for_another_computer() {
|
||||
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
|
||||
use lightcraft_denoise::remote::Remote;
|
||||
use lightcraft_denoise::run::TileRunner;
|
||||
let models = tmp("denoise-models");
|
||||
let _ = std::fs::remove_dir_all(&models);
|
||||
let dir = models.join("synthetic");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = DenoiserManifest {
|
||||
id: "synthetic".into(),
|
||||
name: "Synthetic".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: "made for a test".into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 64,
|
||||
overlap: 16,
|
||||
gain: Gain::None,
|
||||
};
|
||||
let onnx = dir.join("model.onnx");
|
||||
std::fs::write(&onnx, lightcraft_denoise::synthetic::unet_onnx(64, 8, 2, 7)).unwrap();
|
||||
std::fs::write(dir.join("denoise-model.json"), serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
std::fs::write(models.join("settings.json"), r#"{"model": "synthetic"}"#).unwrap();
|
||||
let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap();
|
||||
|
||||
// without its token the server doesn't start
|
||||
let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
|
||||
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
|
||||
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let token = "cli-test-token-0123456789";
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"])
|
||||
.env("LIGHTCRAFT_DENOISE_TOKEN", token)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// wait for the banner
|
||||
let mut err = BufReader::new(child.stderr.take().unwrap());
|
||||
let mut banner = String::new();
|
||||
err.read_line(&mut banner).unwrap();
|
||||
assert!(banner.contains("synthetic 1") && banner.contains(&addr), "{banner}");
|
||||
|
||||
let remote = Remote::new(&addr, token, &sha, 64);
|
||||
let info = remote.hello().unwrap();
|
||||
assert!(info.device.starts_with("CPU"), "{info:?}");
|
||||
let (input, _) = lightcraft_denoise::runtime::test_tile(64);
|
||||
let got = remote.try_run(&input).unwrap().unwrap();
|
||||
let want = lightcraft_denoise::runtime::CpuRunner::load(&onnx, &manifest).unwrap().run(&input).unwrap();
|
||||
let scale = want.iter().fold(1e-9f32, |m, v| m.max(v.abs()));
|
||||
let worst = want.iter().zip(&got).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
|
||||
assert!(worst < 5e-3, "remote vs local: {worst}");
|
||||
assert!(Remote::new(&addr, "a-wrong-token-000000", &sha, 64).hello().is_err());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = std::fs::remove_dir_all(&models);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
|
||||
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
|
||||
//! `docs/web.md`). Differences from the desktop host:
|
||||
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
|
||||
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
|
||||
|
||||
@@ -180,6 +180,7 @@ pub fn mime_for(name: &str) -> &'static str {
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
Some("jxl") => "image/jxl",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
picker: None,
|
||||
pick_display_profile: None,
|
||||
screen_profile: None,
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
@@ -213,6 +215,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
// Face models need a folder to live in; the web has none.
|
||||
pick_model_file: None,
|
||||
save_preset_file: None,
|
||||
pick_keyword_list: None,
|
||||
save_keyword_list: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
write: Some(Box::new(download)),
|
||||
@@ -224,11 +228,15 @@ 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")?;
|
||||
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
|
||||
})),
|
||||
pick_folder: None,
|
||||
// the browser reports prefers-color-scheme to egui
|
||||
system_theme: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ pub struct WireJob {
|
||||
pub raw: bool,
|
||||
#[serde(default)]
|
||||
pub relative_wb: bool,
|
||||
#[serde(default)]
|
||||
pub legacy_relative_wb: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
@@ -62,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,
|
||||
};
|
||||
@@ -74,6 +76,7 @@ impl WireJob {
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
relative_wb: job.info.relative_wb,
|
||||
legacy_relative_wb: job.info.legacy_relative_wb,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
@@ -97,6 +100,7 @@ impl WireJob {
|
||||
as_shot_tint: self.as_shot_tint,
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
legacy_relative_wb: self.legacy_relative_wb,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -113,6 +117,7 @@ impl WireJob {
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
proof: None,
|
||||
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,13 +188,18 @@ impl WorkerCore {
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
let mut rendered = match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
};
|
||||
// an HDR edit's histogram shows its HDR range (as on native)
|
||||
if let Some(h) = lightcraft_engine::pipeline::hdr_histogram(&src.image, &info, &job.settings, &job.request()) {
|
||||
rendered.histogram = h;
|
||||
}
|
||||
Ok(rendered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,11 +341,46 @@ impl CacheWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands out the positions that render workers will have in the main thread's worker list.
|
||||
/// A worker that fails to start gets no position, so the position a callback captures stays the
|
||||
/// index of its own worker in that list (#229).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkerSlots {
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl WorkerSlots {
|
||||
/// The position the worker being started will have, if it comes up.
|
||||
pub fn peek(&self) -> usize {
|
||||
self.next
|
||||
}
|
||||
|
||||
/// The worker at [`Self::peek`] came up and was added to the list.
|
||||
pub fn commit(&mut self) {
|
||||
self.next += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
// Issue #229: a failed worker attempt must not shift the positions of the workers after it
|
||||
#[test]
|
||||
fn a_failed_worker_attempt_leaves_no_gap_in_the_slots() {
|
||||
let mut slots = WorkerSlots::default();
|
||||
let mut taken = Vec::new();
|
||||
for attempt_ok in [false, true, true, true] {
|
||||
let i = slots.peek();
|
||||
if attempt_ok {
|
||||
taken.push(i);
|
||||
slots.commit();
|
||||
}
|
||||
}
|
||||
assert_eq!(taken, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_index_changes_disk_namespace_and_survives_restart() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
@@ -420,6 +465,7 @@ mod tests {
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
relative_wb: false,
|
||||
legacy_relative_wb: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
|
||||
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, WorkerSlots, thumb_storage_key};
|
||||
|
||||
/// Storage key of the thumbnail index.
|
||||
pub const THUMB_INDEX: &str = "thumbs/index.json";
|
||||
@@ -127,11 +127,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
Ok((r, stored)) => {
|
||||
let rgba = Uint8Array::from(r.image.as_bytes().as_ref());
|
||||
let h = &r.histogram;
|
||||
let hist = Uint32Array::new_with_length(4 * 256 + 1);
|
||||
for (i, ch) in [&h.r, &h.g, &h.b, &h.luma].into_iter().enumerate() {
|
||||
hist.subarray((i * 256) as u32, ((i + 1) * 256) as u32).copy_from(ch);
|
||||
}
|
||||
hist.set_index(1024, h.total);
|
||||
let hist = Uint32Array::from(h.to_words().as_slice());
|
||||
set(&out, "ok", &true.into());
|
||||
set(&out, "w", &(r.image.width as u32).into());
|
||||
set(&out, "h", &(r.image.height as u32).into());
|
||||
@@ -245,14 +241,17 @@ impl Workers {
|
||||
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
|
||||
return w;
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut slots = WorkerSlots::default();
|
||||
for attempt in 0..n {
|
||||
// The callbacks name their worker by its place in `workers`; a failed attempt takes none.
|
||||
let i = slots.peek();
|
||||
let opts = web_sys::WorkerOptions::new();
|
||||
opts.set_type(web_sys::WorkerType::Module);
|
||||
opts.set_name(&format!("lightcraft-render-{i}"));
|
||||
opts.set_name(&format!("lightcraft-render-{attempt}"));
|
||||
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
log::warn!("render worker {attempt}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -268,10 +267,15 @@ impl Workers {
|
||||
set(&init, "module", &module);
|
||||
set(&init, "store", &store.into());
|
||||
if let Err(e) = worker.post_message(&init) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
log::warn!("render worker {attempt}: {e:?}");
|
||||
// It never joins the list, so it must not answer to the index of the next worker.
|
||||
worker.set_onmessage(None);
|
||||
worker.set_onerror(None);
|
||||
worker.terminate();
|
||||
continue;
|
||||
}
|
||||
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
|
||||
slots.commit();
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -291,8 +295,10 @@ impl Workers {
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered: Err(msg),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
g.done.push((b.slot, r, 0.0));
|
||||
}
|
||||
@@ -316,12 +322,8 @@ impl Workers {
|
||||
let (wd, ht) = (get(&data, "w").as_f64().unwrap_or(0.0) as usize, get(&data, "h").as_f64().unwrap_or(0.0) as usize);
|
||||
let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();
|
||||
let hist = Uint32Array::new(&get(&data, "hist")).to_vec();
|
||||
match Rgba8::from_bytes(wd, ht, &rgba) {
|
||||
Some(image) if hist.len() == 1025 => {
|
||||
let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec();
|
||||
let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] };
|
||||
Ok(Rendered { image, histogram, deep: None })
|
||||
}
|
||||
match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {
|
||||
(Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),
|
||||
_ => Err("render worker: bad image".to_string()),
|
||||
}
|
||||
} else {
|
||||
@@ -377,8 +379,10 @@ impl Workers {
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered,
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
},
|
||||
ms,
|
||||
));
|
||||
@@ -429,8 +433,10 @@ impl RenderOffload for Workers {
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||||
gpu_fallback: None,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
};
|
||||
g.done.push((slot, r, 0.0));
|
||||
return None;
|
||||
|
||||
@@ -23,17 +23,28 @@ lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# probing the window's renderer before it opens (`window_gpu`)
|
||||
pollster = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { workspace = true }
|
||||
objc2 = { workspace = true }
|
||||
objc2-app-kit = { workspace = true }
|
||||
objc2-foundation = { workspace = true }
|
||||
@@ -0,0 +1,198 @@
|
||||
//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux
|
||||
//! desktops whose windowing layer reports no theme to egui (many Wayland compositors).
|
||||
//!
|
||||
//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker
|
||||
//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a
|
||||
//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer.
|
||||
//! Elsewhere egui's own report (`Context::system_theme`) is used.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
|
||||
/// another variant inside it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
|
||||
/// absolute path, and is never repeated.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake the first frame after a slow initial Read as well as subsequent real changes.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock<egui::Context>, code: u8) {
|
||||
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's system-appearance service (Linux), or `None` where egui's report is enough.
|
||||
pub fn service() -> Option<lightcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("appearance-portal".into())
|
||||
.spawn(move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before Read so a change during startup is queued rather than lost.
|
||||
let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
publish(&worker_value, &worker_wake, code_of(initial));
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
// Blocks until the portal sends a signal: no polling.
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
publish(&worker_value, &worker_wake, code);
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The first frame shows the right theme when the portal answers quickly; a slow one
|
||||
// only delays the switch (the worker wakes the UI when the value changes).
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn delayed_initial_read_repaints_only_on_change() {
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, AtomicUsize, Ordering},
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let settle = |ctx: &egui::Context| {
|
||||
for _ in 0..8 {
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
settle(&ctx);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = Arc::clone(&calls);
|
||||
ctx.set_request_repaint_callback(move |_| {
|
||||
observed.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
let wake = OnceLock::new();
|
||||
assert!(wake.set(ctx.clone()).is_ok());
|
||||
let value = AtomicU8::new(0);
|
||||
super::publish(&value, &wake, 2);
|
||||
assert_eq!(value.load(Ordering::Relaxed), 2);
|
||||
let after_change = calls.load(Ordering::Relaxed);
|
||||
assert!(after_change > 0);
|
||||
settle(&ctx);
|
||||
let after_settle = calls.load(Ordering::Relaxed);
|
||||
super::publish(&value, &wake, 2);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_values_and_gsettings_output_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
let plain = zbus::zvariant::OwnedValue::from(1u32);
|
||||
assert_eq!(super::portal_code(plain), Some(1));
|
||||
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
|
||||
assert_eq!(super::portal_code(wrong), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::parse_gtk_scheme(""), None);
|
||||
for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] {
|
||||
assert_eq!(super::decode(u32::from(super::code_of(theme))), theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it,
|
||||
//! or any other application the user named in Settings ▸ External Editing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`).
|
||||
const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT;
|
||||
/// PhotoCraft's application id (Flatpak, desktop entry).
|
||||
const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// How an editor is started.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Launcher {
|
||||
/// The system's default application for the file.
|
||||
Default,
|
||||
/// A program file, given the photo as its argument.
|
||||
Program(PathBuf),
|
||||
/// macOS: `open -a <app>` (an application name or bundle path).
|
||||
MacApp(String),
|
||||
/// The PhotoCraft Flatpak, with the file forwarded into its sandbox.
|
||||
Flatpak,
|
||||
}
|
||||
|
||||
/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default):
|
||||
/// for PhotoCraft, that it is installed. The error says what to do instead.
|
||||
pub fn check(app: &str) -> Result<(), String> {
|
||||
launcher(app).map(|_| ())
|
||||
}
|
||||
|
||||
/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside.
|
||||
pub fn open_with(path: &str, app: &str) -> Result<(), String> {
|
||||
let mut c = match launcher(app)? {
|
||||
Launcher::Default if cfg!(target_os = "macos") => {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
}
|
||||
Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path),
|
||||
Launcher::Default => {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
}
|
||||
Launcher::MacApp(app) => {
|
||||
let mut c = Command::new("open");
|
||||
c.args(["-a", &app, path]);
|
||||
c
|
||||
}
|
||||
Launcher::Program(p) => {
|
||||
let mut c = Command::new(p);
|
||||
c.arg(path);
|
||||
c
|
||||
}
|
||||
Launcher::Flatpak => {
|
||||
let mut c = Command::new("flatpak");
|
||||
c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]);
|
||||
c
|
||||
}
|
||||
};
|
||||
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).
|
||||
fn windows_default_command(path: &str) -> Command {
|
||||
let mut c = Command::new("explorer.exe");
|
||||
c.arg(path.replace('/', "\\"));
|
||||
c
|
||||
}
|
||||
|
||||
fn launcher(app: &str) -> Result<Launcher, String> {
|
||||
let app = app.trim();
|
||||
if app.is_empty() {
|
||||
return Ok(Launcher::Default);
|
||||
}
|
||||
if !app.eq_ignore_ascii_case(PHOTOCRAFT) {
|
||||
return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) });
|
||||
}
|
||||
find_photocraft().ok_or_else(|| {
|
||||
"PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn find_photocraft() -> Option<Launcher> {
|
||||
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string());
|
||||
if cfg!(target_os = "macos") {
|
||||
// LaunchServices finds the app wherever it is; these are only the usual places
|
||||
let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app"));
|
||||
let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir());
|
||||
return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string())));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir"));
|
||||
let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None);
|
||||
return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program);
|
||||
}
|
||||
// Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which
|
||||
// registers one on first run), else the Flatpak
|
||||
let on_path =
|
||||
env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).map(|d| d.join("photocraft")).find(|p| p.is_file());
|
||||
let from_entry = || {
|
||||
desktop_entry_dirs(env).into_iter().find_map(|d| {
|
||||
let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?;
|
||||
desktop_exec(&text).filter(|p| p.is_file())
|
||||
})
|
||||
};
|
||||
if let Some(p) = on_path.or_else(from_entry) {
|
||||
return Some(Launcher::Program(p));
|
||||
}
|
||||
let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success());
|
||||
flatpak.then_some(Launcher::Flatpak)
|
||||
}
|
||||
|
||||
/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI
|
||||
/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default
|
||||
/// folders (per machine, then per user).
|
||||
fn windows_candidates(install_dir: Option<String>, app_path: Option<String>, env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe")));
|
||||
out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"'))));
|
||||
for var in ["ProgramW6432", "ProgramFiles"] {
|
||||
out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe")));
|
||||
}
|
||||
out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe")));
|
||||
out
|
||||
}
|
||||
|
||||
/// A string value from the Windows registry (`reg query`; `None` = the key's default value).
|
||||
fn reg_query(key: &str, name: Option<&str>) -> Option<String> {
|
||||
let mut c = Command::new("reg");
|
||||
c.args(["query", key]);
|
||||
match name {
|
||||
Some(n) => c.args(["/v", n]),
|
||||
None => c.arg("/ve"),
|
||||
};
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let out = c.output().ok().filter(|o| o.status.success())?;
|
||||
reg_value(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line.
|
||||
fn reg_value(output: &str) -> Option<String> {
|
||||
output.lines().find_map(|l| {
|
||||
let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?;
|
||||
Some(v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// The folders desktop entries live in (XDG base directories).
|
||||
fn desktop_entry_dirs(env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
match env("XDG_DATA_HOME") {
|
||||
Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)),
|
||||
_ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))),
|
||||
}
|
||||
let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from));
|
||||
dirs.into_iter().map(|d| d.join("applications")).collect()
|
||||
}
|
||||
|
||||
/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped).
|
||||
fn desktop_exec(entry: &str) -> Option<PathBuf> {
|
||||
let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim();
|
||||
let program = match exec.strip_prefix('"') {
|
||||
Some(rest) => rest.split('"').next()?,
|
||||
None => exec.split_whitespace().next()?,
|
||||
};
|
||||
// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not
|
||||
/// through a shell.
|
||||
#[test]
|
||||
fn windows_default_open_goes_through_explorer_not_cmd() {
|
||||
let path = r"C:/Edits/a & b ^c %PATH% | d.psd";
|
||||
let c = windows_default_command(path);
|
||||
assert_eq!(c.get_program(), "explorer.exe");
|
||||
let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
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 {
|
||||
"ProgramFiles" => Some(r"C:\Program Files".to_string()),
|
||||
"LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env);
|
||||
let c: Vec<String> = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect();
|
||||
assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}");
|
||||
assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped");
|
||||
assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}");
|
||||
assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}");
|
||||
assert_eq!(c.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reg_query_output_is_read() {
|
||||
let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n";
|
||||
assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\"));
|
||||
let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n";
|
||||
assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe"));
|
||||
assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_entries_give_their_program() {
|
||||
let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n";
|
||||
assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage")));
|
||||
assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft")));
|
||||
assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup");
|
||||
assert_eq!(desktop_exec("[Desktop Entry]\n"), None);
|
||||
let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string()));
|
||||
assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications"));
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/share/applications")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_editors_are_used_as_named() {
|
||||
assert_eq!(launcher("").unwrap(), Launcher::Default);
|
||||
assert_eq!(launcher(" ").unwrap(), Launcher::Default);
|
||||
let named = launcher("/usr/bin/gimp").unwrap();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into()));
|
||||
} else {
|
||||
assert_eq!(named, Launcher::Program("/usr/bin/gimp".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+371
-107
@@ -23,11 +23,19 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
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;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod screen_profile;
|
||||
mod window_gpu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
use lightcraft_engine::Session;
|
||||
@@ -59,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);
|
||||
@@ -71,27 +79,48 @@ impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
log::error!("saving the library failed: {e}");
|
||||
}
|
||||
// last, with everything saved: no render may be inside the GPU driver when the process ends
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long quitting waits for renders that are running (issue #620). A GPU render takes well under
|
||||
/// a second; anything slower gives way to quitting.
|
||||
const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
|
||||
#[cfg(test)]
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut c = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
|
||||
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
|
||||
}
|
||||
c
|
||||
window_gpu::base_options()
|
||||
}
|
||||
|
||||
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
|
||||
/// and `LIGHTCRAFT_NO_PREFS`.
|
||||
fn window_files(in_memory: bool) -> window_gpu::Files {
|
||||
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
|
||||
}
|
||||
|
||||
/// Written while the GPU compute device is created, removed once that returned
|
||||
@@ -133,6 +162,23 @@ fn gpu_crash_notice(what: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
|
||||
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
|
||||
fn display_profile_dir() -> Option<std::path::PathBuf> {
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
|
||||
let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
|
||||
let candidates: Vec<Option<std::path::PathBuf>> = if cfg!(target_os = "macos") {
|
||||
vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
|
||||
} else {
|
||||
let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
|
||||
vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
|
||||
};
|
||||
candidates.into_iter().flatten().find(|d| d.is_dir())
|
||||
}
|
||||
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::config::config_dir()
|
||||
}
|
||||
@@ -158,6 +204,23 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
|
||||
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
|
||||
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
|
||||
let recovered =
|
||||
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
|
||||
Ok(Some(workspace)) => workspace.validate().is_err(),
|
||||
Ok(None) => false,
|
||||
Err(_) => true,
|
||||
});
|
||||
if recovered {
|
||||
fields.remove("docking");
|
||||
}
|
||||
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
|
||||
Ok((ui, recovered))
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
@@ -167,8 +230,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
match decode_saved_ui(&bytes) {
|
||||
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
|
||||
Ok((ui, true)) => {
|
||||
let msg = format!(
|
||||
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
|
||||
path.display()
|
||||
);
|
||||
(Some(ui.sanitized()), Some(msg), true)
|
||||
}
|
||||
Err(e) if !set_aside_damaged => {
|
||||
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
|
||||
(None, Some(msg), true)
|
||||
@@ -245,6 +315,9 @@ impl PrefsWriter {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
if let Some(workspace) = &ui.docking {
|
||||
workspace.validate()?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
@@ -323,9 +396,27 @@ fn explorer_select_command(path: &str) -> std::process::Command {
|
||||
c
|
||||
}
|
||||
|
||||
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
|
||||
/// for Help ▸ Open Log Folder.
|
||||
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
|
||||
/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
|
||||
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
|
||||
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
|
||||
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
|
||||
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
|
||||
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
|
||||
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
|
||||
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
|
||||
static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
|
||||
|
||||
/// A file dialog owned by the app's window, or a plain one before the window exists.
|
||||
fn file_dialog() -> rfd::FileDialog {
|
||||
DIALOG_PARENT.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
|
||||
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
|
||||
fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
|
||||
Services {
|
||||
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
|
||||
// stopped the window answering the compositor, which then reported the app as hung.
|
||||
@@ -336,7 +427,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
let mut d = rfd::FileDialog::new();
|
||||
let mut d = file_dialog();
|
||||
if let Some(t) = &req.title {
|
||||
d = d.set_title(t);
|
||||
}
|
||||
@@ -362,37 +453,18 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
})),
|
||||
log_file: log_file.map(|p| p.display().to_string()),
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
open_with: Some(Box::new(|path: &str, app: &str| {
|
||||
// spawned, never waited for: the editor runs alongside
|
||||
let app = app.trim();
|
||||
let mut c = if cfg!(target_os = "macos") {
|
||||
let mut c = std::process::Command::new("open");
|
||||
if !app.is_empty() {
|
||||
c.args(["-a", app]);
|
||||
}
|
||||
c
|
||||
} else if app.is_empty() {
|
||||
if cfg!(target_os = "windows") {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/C", "start", ""]);
|
||||
c
|
||||
} else {
|
||||
std::process::Command::new("xdg-open")
|
||||
}
|
||||
} else {
|
||||
std::process::Command::new(app)
|
||||
};
|
||||
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
open_with: Some(Box::new(editors::open_with)),
|
||||
open_all_with: Some(Box::new(editors::open_all_with)),
|
||||
check_editor: Some(Box::new(editors::check)),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if !url.starts_with("https://") {
|
||||
return Err("only https links are opened".into());
|
||||
@@ -420,7 +492,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
|
||||
})),
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
@@ -429,14 +501,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.collect()
|
||||
})),
|
||||
pick_denoise_model: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().into_owned()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter_nocase(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
@@ -449,7 +521,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.collect()
|
||||
})),
|
||||
pick_model_file: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
|
||||
.pick_file()
|
||||
@@ -457,23 +529,56 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
// 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)),
|
||||
// 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()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
|
||||
if let Some(dir) = display_profile_dir() {
|
||||
d = d.set_directory(dir);
|
||||
}
|
||||
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_keyword_list: Some(Box::new(|| {
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_keyword_list: Some(Box::new(|name: &str| {
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
@@ -483,7 +588,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
.collect()
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
@@ -499,6 +604,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
// the library is a folder on disk: backed up with the user's other files
|
||||
backup_library: None,
|
||||
restore_library: None,
|
||||
system_theme: appearance::service(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +612,8 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
|
||||
s.apple_photos_background = true;
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
@@ -558,6 +666,7 @@ OPTIONS:
|
||||
--version, --help
|
||||
|
||||
ENVIRONMENT:
|
||||
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
|
||||
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;
|
||||
@@ -613,10 +722,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
port
|
||||
}
|
||||
|
||||
/// An isolated profile keeps the emergency crash log with its other app data. With no
|
||||
/// override (or an empty override), retain the historical system-temp location.
|
||||
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
|
||||
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
@@ -687,7 +802,14 @@ fn main() -> eframe::Result {
|
||||
let gpu_crash = gpu_crash_check(in_memory);
|
||||
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
||||
lightcraft_engine::gpu::set_enabled(gpu_on);
|
||||
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
|
||||
// build egui's pipeline never kills the start-up
|
||||
let win_files = std::sync::Arc::new(window_files(in_memory));
|
||||
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
|
||||
win_files.arm(window_renderer);
|
||||
let win_files_app = win_files.clone();
|
||||
let options = eframe::NativeOptions {
|
||||
renderer,
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
@@ -699,73 +821,100 @@ fn main() -> eframe::Result {
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
|
||||
.with_app_id(APP_ID),
|
||||
wgpu_options: window_wgpu_options(),
|
||||
wgpu_options,
|
||||
..Default::default()
|
||||
};
|
||||
// the app's copy: `log_file` is still named in the message of a start that fails
|
||||
let app_log_file = log_file.clone();
|
||||
let started = eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir =
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
app.notices.extend(launch_notices);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
// after the snapshot above, so the writer saves the switched-off preference
|
||||
if let Some(notice) = gpu_crash {
|
||||
app.ui.settings.gpu = false;
|
||||
app.notices.push(notice);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; LIGHTCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("LIGHTCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// the window's renderer is up (eframe calls this after creating it)
|
||||
win_files_app.started(window_renderer);
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// (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;
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
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() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
// after the snapshot above, so the writer saves the switched-off preference
|
||||
if let Some(notice) = gpu_crash {
|
||||
app.ui.settings.gpu = false;
|
||||
app.notices.push(notice);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
// listed and read on the import worker once the window shows (issue #374: never
|
||||
// here, before the first frame, where a large folder kept the window from
|
||||
// appearing)
|
||||
if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
|
||||
log::warn!("import at launch: {e}");
|
||||
}
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
);
|
||||
let menu =
|
||||
(std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}));
|
||||
// a panic that still escaped the renderer's start (the probe and error handler should
|
||||
// prevent it): an error like any other failed start
|
||||
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
|
||||
if let Err(e) = &started {
|
||||
// the renderer never came up: start again, which tries the next one in the chain
|
||||
if window_gpu::relaunch_after_failure(&win_files) {
|
||||
log::error!("LightCraft could not open its window with {}: {e}; trying the next renderer", window_renderer.name());
|
||||
return Ok(());
|
||||
}
|
||||
startup_failed(&e.to_string(), log_file.as_deref());
|
||||
}
|
||||
started
|
||||
@@ -818,6 +967,16 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
|
||||
/// adds to Apple Photos on a worker (headless sessions wait instead).
|
||||
#[test]
|
||||
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
|
||||
let (s, problem) = open_session(true, None, false);
|
||||
assert!(problem.is_none());
|
||||
assert!(s.apple_photos_background);
|
||||
assert!(!lightcraft_engine::Session::new().apple_photos_background);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
@@ -854,6 +1013,59 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docking_survives_native_settings_file_reload() {
|
||||
use lightcraft_ui_egui::docking::Panel;
|
||||
let d = dir("docking-reload");
|
||||
let path = d.join("ui.json");
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
|
||||
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
writer.save_ui(&app.ui, false).unwrap();
|
||||
let (restored, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(warning.is_none());
|
||||
assert!(!keep);
|
||||
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
restored_app.ui = restored.unwrap();
|
||||
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
|
||||
assert_eq!(
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
|
||||
[70.0, 80.0, 310.0, 500.0]
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
|
||||
let d = dir("future-docking");
|
||||
let path = d.join("ui.json");
|
||||
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
|
||||
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
|
||||
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
|
||||
let mut saved = serde_json::to_value(&ui).unwrap();
|
||||
saved["docking"] = docking;
|
||||
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
|
||||
let bytes = serde_json::to_vec(&saved).unwrap();
|
||||
for set_aside in [false, true] {
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
|
||||
let loaded = loaded.unwrap();
|
||||
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
|
||||
assert_eq!(loaded.thumb_size, ui.thumb_size);
|
||||
assert!(!loaded.filmstrip);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(warning.unwrap().contains("Other settings were loaded"));
|
||||
assert!(keep);
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
|
||||
writer.save_ui(&loaded, false).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
|
||||
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
|
||||
#[test]
|
||||
@@ -949,6 +1161,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
|
||||
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
|
||||
#[test]
|
||||
fn window_has_no_indirect_call_validation_pipeline() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
|
||||
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
|
||||
#[test]
|
||||
@@ -1005,4 +1227,46 @@ 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)]
|
||||
mod config_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_override_keeps_panic_log_in_isolated_profile() {
|
||||
let temp = std::path::PathBuf::from("temp");
|
||||
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
|
||||
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
|
||||
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The ICC profile of the screen showing LightCraft's window (macOS).
|
||||
//!
|
||||
//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's
|
||||
//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so
|
||||
//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in
|
||||
//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the
|
||||
//! previews are shown through this profile instead (`Services::screen_profile`).
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSScreen};
|
||||
|
||||
/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None`
|
||||
/// when it isn't known: off the main thread, or a screen without a usable profile.
|
||||
pub fn icc() -> Option<Vec<u8>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject());
|
||||
let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?;
|
||||
let icc = screen.colorSpace()?.ICCProfileData()?.to_vec();
|
||||
(!icc.is_empty()).then_some(icc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn unknown_off_the_main_thread() {
|
||||
// AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour
|
||||
assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! The window's renderer and its fallback chain (crash report: on some Windows machines FXC fails
|
||||
//! to compile egui's own shaders on DX12 — "The parameter is incorrect. (0x80070057)" in
|
||||
//! `egui_pipeline` — and wgpu's default error handler panicked, killing the app at start-up).
|
||||
//!
|
||||
//! - **Windows:** each renderer of [`backend::window_renderer_order`] (DX12, Vulkan, GL, then
|
||||
//! eframe's glow) is *probed* before the window opens: a device is created with an error handler
|
||||
//! that logs instead of panicking, and egui's pipeline is built inside error scopes. The first
|
||||
//! that builds cleanly is handed to eframe as an existing device (so its error handler stays).
|
||||
//! - **Everywhere:** a marker file names the renderer being started and is removed once the
|
||||
//! window's renderer is up. Left behind — the start died or panicked — the next launch tries that
|
||||
//! renderer last; [`relaunch_after_failure`] starts that next launch right away.
|
||||
//! - The renderer that worked is remembered (`<config>/window-renderer`, Windows only) and tried
|
||||
//! first next time. `LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|gl|glow` (or `WGPU_BACKEND`) forces one.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup, WgpuSetupExisting};
|
||||
use eframe::wgpu;
|
||||
use lightcraft_engine::gpu::backend::{self, Backends, WindowRenderer};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Environment variable counting automatic relaunches after a failed start (bounds the chain).
|
||||
const RETRY_VAR: &str = "LIGHTCRAFT_WINDOW_RETRY";
|
||||
/// Never more relaunches than renderers to try.
|
||||
const MAX_RETRIES: u32 = 4;
|
||||
|
||||
/// The window's renderer for this start.
|
||||
pub struct Chosen {
|
||||
pub renderer: eframe::Renderer,
|
||||
pub wgpu_options: WgpuConfiguration,
|
||||
pub which: WindowRenderer,
|
||||
/// For the user: the last start failed with another renderer, or one failed now.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// Files under the settings folder (`None` dir: write nothing — `--memory`, `LIGHTCRAFT_NO_PREFS`).
|
||||
pub struct Files {
|
||||
dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
pub fn new(dir: Option<PathBuf>) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
fn marker(&self) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join("window-init.marker"))
|
||||
}
|
||||
fn remembered(&self) -> Option<PathBuf> {
|
||||
self.dir.as_ref().map(|d| d.join("window-renderer"))
|
||||
}
|
||||
fn read(p: Option<PathBuf>) -> Option<WindowRenderer> {
|
||||
std::fs::read_to_string(p?).ok().as_deref().and_then(WindowRenderer::parse)
|
||||
}
|
||||
fn write(p: Option<PathBuf>, r: WindowRenderer) {
|
||||
let Some(p) = p else { return };
|
||||
if let Some(d) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&p, format!("{}\n", r.name())) {
|
||||
log::warn!("window: could not write {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
/// The renderer the last start died in (marker left behind), removing the marker.
|
||||
pub fn take_failed(&self) -> Option<WindowRenderer> {
|
||||
let m = self.marker()?;
|
||||
let r = Self::read(Some(m.clone()));
|
||||
let _ = std::fs::remove_file(m);
|
||||
r
|
||||
}
|
||||
/// Before the window opens: record what is being started.
|
||||
pub fn arm(&self, r: WindowRenderer) {
|
||||
Self::write(self.marker(), r);
|
||||
}
|
||||
/// The window's renderer is up: clear the marker and (Windows) remember it.
|
||||
pub fn started(&self, r: WindowRenderer) {
|
||||
if let Some(m) = self.marker() {
|
||||
let _ = std::fs::remove_file(m);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
Self::write(self.remembered(), r);
|
||||
}
|
||||
}
|
||||
/// The marker is still there: the window never came up.
|
||||
pub fn armed(&self) -> bool {
|
||||
self.marker().is_some_and(|m| m.exists())
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer for this start: see the module docs.
|
||||
pub fn choose(files: &Files) -> Chosen {
|
||||
let forced =
|
||||
backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref());
|
||||
let failed = files.take_failed();
|
||||
let remembered = if cfg!(windows) { Files::read(files.remembered()) } else { None };
|
||||
let compiled = wgpu::Instance::enabled_backend_features();
|
||||
let os = std::env::consts::OS;
|
||||
let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true);
|
||||
let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name()));
|
||||
let mut problems = Vec::new();
|
||||
let chosen = if cfg!(windows) {
|
||||
pick_probed(&order, &mut problems)
|
||||
} else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) {
|
||||
// elsewhere eframe's own wgpu set-up stays the default; glow after a failed start
|
||||
Some((WindowRenderer::Glow, None))
|
||||
} else {
|
||||
Some((WindowRenderer::Wgpu(backend::window_backends()), None))
|
||||
};
|
||||
let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None));
|
||||
if !problems.is_empty() {
|
||||
notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string());
|
||||
}
|
||||
let notice = notice.map(|n| {
|
||||
format!(
|
||||
"{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \
|
||||
(see docs/gpu-pipeline.md → Troubleshooting).",
|
||||
which.name(),
|
||||
app = "LightCraft",
|
||||
var = "LIGHTCRAFT_GPU_BACKEND",
|
||||
)
|
||||
});
|
||||
log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::<Vec<_>>());
|
||||
let mut wgpu_options = base_options();
|
||||
let renderer = match (which, existing) {
|
||||
(WindowRenderer::Glow, _) => eframe::Renderer::Glow,
|
||||
(WindowRenderer::Wgpu(_), Some(e)) => {
|
||||
wgpu_options.wgpu_setup = WgpuSetup::Existing(e);
|
||||
eframe::Renderer::Wgpu
|
||||
}
|
||||
(WindowRenderer::Wgpu(b), None) => {
|
||||
if let WgpuSetup::CreateNew(n) = &mut wgpu_options.wgpu_setup {
|
||||
n.instance_descriptor.backends = b;
|
||||
}
|
||||
eframe::Renderer::Wgpu
|
||||
}
|
||||
};
|
||||
Chosen { renderer, wgpu_options, which, notice }
|
||||
}
|
||||
|
||||
/// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471).
|
||||
pub fn base_options() -> WgpuConfiguration {
|
||||
let mut c = WgpuConfiguration::default();
|
||||
if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
|
||||
n.instance_descriptor.backends = backend::window_backends();
|
||||
n.instance_descriptor.backend_options = backend::backend_options();
|
||||
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
|
||||
n.instance_descriptor.flags = backend::instance_flags_from_env(n.instance_descriptor.flags);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The first renderer of `order` that probes cleanly (glow is taken without a probe).
|
||||
fn pick_probed(order: &[WindowRenderer], problems: &mut Vec<String>) -> Option<(WindowRenderer, Option<WgpuSetupExisting>)> {
|
||||
for &r in order {
|
||||
match r {
|
||||
WindowRenderer::Glow => return Some((r, None)),
|
||||
WindowRenderer::Wgpu(b) => {
|
||||
let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into()));
|
||||
match probed {
|
||||
Ok(e) => return Some((r, Some(e))),
|
||||
Err(e) => {
|
||||
log::error!("window: the {} renderer failed: {e}", r.name());
|
||||
problems.push(format!("The {} renderer failed ({e}).", r.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned.
|
||||
fn probe(backends: Backends) -> Result<WgpuSetupExisting, String> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends,
|
||||
flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()),
|
||||
backend_options: backend::backend_options(),
|
||||
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
|
||||
display: None,
|
||||
});
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance),
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() };
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("egui wgpu device"),
|
||||
required_limits: wgpu::Limits { max_texture_dimension_2d: 8192, ..base },
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| format!("{}: no device: {e}", adapter.get_info().name))?;
|
||||
install_error_handler(&device);
|
||||
check_pipeline(&device, |d| {
|
||||
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
|
||||
})?;
|
||||
Ok(WgpuSetupExisting { instance, adapter, device, queue })
|
||||
}
|
||||
|
||||
/// wgpu's default handler for errors outside an error scope panics ("Handling wgpu errors as
|
||||
/// fatal by default"); this one logs.
|
||||
pub fn install_error_handler(device: &wgpu::Device) {
|
||||
device.on_uncaptured_error(std::sync::Arc::new(|e| log::error!("window: wgpu error: {e}")));
|
||||
}
|
||||
|
||||
/// Run `build` inside validation, internal and out-of-memory error scopes: the first error is
|
||||
/// returned instead of reaching the uncaptured-error handler.
|
||||
pub fn check_pipeline(device: &wgpu::Device, build: impl FnOnce(&wgpu::Device)) -> Result<(), String> {
|
||||
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
build(device);
|
||||
let errors = [pollster::block_on(validation.pop()), pollster::block_on(internal.pop()), pollster::block_on(oom.pop())];
|
||||
match errors.into_iter().flatten().next() {
|
||||
Some(e) => Err(e.to_string()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The start failed (error or panic) before the window's renderer came up: start the app again,
|
||||
/// which then tries the next renderer. `false`: not relaunched (it came up, nothing written to
|
||||
/// disk, or the retries are used up).
|
||||
pub fn relaunch_after_failure(files: &Files) -> bool {
|
||||
if !files.armed() {
|
||||
return false;
|
||||
}
|
||||
let tries = std::env::var(RETRY_VAR).ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
|
||||
if tries >= MAX_RETRIES {
|
||||
return false;
|
||||
}
|
||||
let Ok(exe) = std::env::current_exe() else { return false };
|
||||
log::warn!("window: the renderer failed to start; starting again with the next one");
|
||||
std::process::Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.env(RETRY_VAR, (tries + 1).to_string())
|
||||
.spawn()
|
||||
.map_err(|e| log::error!("window: could not start again: {e}"))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("app-window-gpu-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// The marker names the renderer being started; left behind, the next start reads it as the
|
||||
/// failed one (and removes it); a good start removes it and remembers the renderer (Windows).
|
||||
#[test]
|
||||
fn marker_records_the_failed_renderer() {
|
||||
let d = dir("marker");
|
||||
let f = Files::new(Some(d.clone()));
|
||||
let dx = WindowRenderer::Wgpu(Backends::DX12);
|
||||
f.arm(dx);
|
||||
assert!(f.armed());
|
||||
assert_eq!(f.take_failed(), Some(dx));
|
||||
assert!(!f.armed());
|
||||
assert_eq!(f.take_failed(), None);
|
||||
f.arm(WindowRenderer::Glow);
|
||||
f.started(WindowRenderer::Glow);
|
||||
assert!(!f.armed());
|
||||
assert_eq!(Files::read(f.remembered()), cfg!(windows).then_some(WindowRenderer::Glow));
|
||||
// nothing written without a settings folder
|
||||
let none = Files::new(None);
|
||||
none.arm(dx);
|
||||
assert!(!none.armed() && !relaunch_after_failure(&none));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// The crash report: a pipeline that fails validation must come back as an error, never as
|
||||
/// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU).
|
||||
#[test]
|
||||
fn pipeline_errors_are_captured_not_panics() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: backend::window_backends(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
|
||||
eprintln!("no GPU device: skipped");
|
||||
return;
|
||||
};
|
||||
install_error_handler(&device);
|
||||
// a shader that does not validate, then a pipeline using it (like a failed FXC compile)
|
||||
let r = check_pipeline(&device, |d| {
|
||||
let m = d.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("broken"),
|
||||
source: wgpu::ShaderSource::Wgsl("@vertex fn vs_main() -> @builtin(position) vec4<f32> { return undefined_thing; }".into()),
|
||||
});
|
||||
let _ = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("egui_pipeline"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState { module: &m, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[] },
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
});
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
// egui's own pipeline builds cleanly here
|
||||
check_pipeline(&device, |d| {
|
||||
drop(eframe::egui_wgpu::Renderer::new(d, wgpu::TextureFormat::Bgra8Unorm, eframe::egui_wgpu::RendererOptions::default()));
|
||||
})
|
||||
.unwrap();
|
||||
// outside a scope: logged by our handler, not a panic
|
||||
let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) });
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
@@ -22,6 +25,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"description": "Sony's global tone curve with the Dynamic Range Optimizer (DRO) off, Standard creative style: knots of scene luminance (white-balanced camera RGB with baseline exposure, through the colour model) to display-linear luminance. The camera look of an ARW shot with DRO on is lowered to it wherever it is darker (crates/engine/src/camera_preview.rs, docs/camera-preview-colour.md, issue #244).",
|
||||
"derivation": "Each source raw was paired with its own embedded camera JPEG at the 96-pixel proxy of the per-photo fit (camera_preview::collect_pairs). The pairs of all 12 files were pooled as one model, as lightcraft-cli calibrate pools a camera profile: a colour matrix and hue/saturation table were fitted to the pool, and the tone curve was quantile-matched (camera_preview::fit_tone) between the pooled scene luminances through that colour model and the JPEG luminances. Knots rounded to 5 decimals. Aggregate statistics only, no image content.",
|
||||
"sources": [
|
||||
{ "body": "ILCA-77M2", "url": "https://raw.pixls.us/data/Sony/ILCA-77M2/_DSC0244.ARW", "sha256": "f41ceff680b675460cc1fe258a24d8c8c01c52a300933f3ab6401d9a20b60dcc", "licence": "CC0" },
|
||||
{ "body": "ILCE-3500", "url": "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", "sha256": "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", "licence": "CC0" },
|
||||
{ "body": "ILCE-5000", "url": "https://raw.pixls.us/data/Sony/ILCE-5000/_DSC0793.ARW", "sha256": "a919c8535eed68577f1745fb9674ccf333962628ccdec6d48a6f635208467f6c", "licence": "CC0" },
|
||||
{ "body": "ILCE-6600", "url": "https://raw.pixls.us/data/Sony/ILCE-6600/_DSC0268.ARW", "sha256": "041d71d70148ca77164f19d061e3582adb34399ea5a4baba32960f2956fb0362", "licence": "CC0" },
|
||||
{ "body": "ILCE-7CR", "url": "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", "sha256": "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", "licence": "CC0" },
|
||||
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/UNCOMPRESSED_14bit.ARW", "sha256": "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", "licence": "CC0" },
|
||||
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0009.ARW", "sha256": "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", "licence": "CC0" },
|
||||
{ "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0010.ARW", "sha256": "3ae2ff75cfa7240d0b4458f662afd28665c5fd1e83d230ac73f5ad69d3df8592", "licence": "CC0" },
|
||||
{ "body": "ILCE-7S", "url": "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", "sha256": "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", "licence": "CC0" },
|
||||
{ "body": "ILCE-9M2", "url": "https://raw.pixls.us/data/Sony/ILCE-9M2/SONY_A9II_(ILCE-9M2)_-_compressed_(3:2)_14bit.ARW", "sha256": "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", "licence": "CC0" },
|
||||
{ "body": "NEX-F3", "url": "https://raw.pixls.us/data/Sony/NEX-F3/_DSC4017.ARW", "sha256": "cfdc60d118b5ce2dce10d459dcb98ad63c6db3e609999932ca8964d86daf836b", "licence": "CC0" },
|
||||
{ "body": "SLT-A57", "url": "https://raw.pixls.us/data/Sony/SLT-A57/a57-06125.arw", "sha256": "0e9064f369a3947f892c1d7b1971f75d3879f5673134932d5cc7a67bf4263ba4", "licence": "CC0" }
|
||||
],
|
||||
"knots": [
|
||||
[0.01655, 0.01799],
|
||||
[0.01970, 0.02376],
|
||||
[0.02212, 0.03008],
|
||||
[0.02469, 0.03634],
|
||||
[0.02737, 0.04320],
|
||||
[0.03049, 0.05044],
|
||||
[0.03425, 0.05812],
|
||||
[0.03824, 0.06667],
|
||||
[0.04232, 0.07817],
|
||||
[0.04658, 0.09059],
|
||||
[0.05095, 0.10268],
|
||||
[0.05550, 0.11731],
|
||||
[0.05962, 0.13173],
|
||||
[0.06409, 0.14529],
|
||||
[0.06843, 0.15939],
|
||||
[0.07314, 0.17363],
|
||||
[0.07800, 0.18897],
|
||||
[0.08298, 0.20502],
|
||||
[0.08909, 0.22159],
|
||||
[0.09693, 0.23964],
|
||||
[0.10540, 0.26082],
|
||||
[0.11579, 0.28882],
|
||||
[0.12763, 0.31986],
|
||||
[0.14079, 0.35198],
|
||||
[0.15786, 0.39293],
|
||||
[0.17949, 0.44101],
|
||||
[0.20330, 0.49407],
|
||||
[0.23466, 0.54244],
|
||||
[0.27207, 0.60078],
|
||||
[0.31673, 0.66533],
|
||||
[0.38682, 0.74143],
|
||||
[0.52622, 0.80307]
|
||||
]
|
||||
}
|
||||
+250
-63
@@ -2,23 +2,23 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/lightcraft",
|
||||
"branch": "main",
|
||||
"head": "88ea1a804b35709e1fe436e3792adf0428b97daa",
|
||||
"head_date": "2026-10-09T13:05:04Z",
|
||||
"commits": 729,
|
||||
"head": "c545cc5e842a74d65f32187d9fdee86ada3099de",
|
||||
"head_date": "2026-10-10T05:40:18Z",
|
||||
"commits": 1033,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 83,
|
||||
"commits": 348,
|
||||
"lines_added": 127920,
|
||||
"lines_deleted": 8745,
|
||||
"prs": 85,
|
||||
"commits": 370,
|
||||
"lines_added": 131038,
|
||||
"lines_deleted": 9013,
|
||||
"binary_added": 38,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T14:04:12Z",
|
||||
"last_commit": "2026-10-09T13:05:04Z"
|
||||
"last_commit": "2026-10-10T05:56:02Z"
|
||||
},
|
||||
{
|
||||
"login": "leipsfur",
|
||||
@@ -63,14 +63,14 @@
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 755,
|
||||
"lines_deleted": 80,
|
||||
"prs": 6,
|
||||
"commits": 6,
|
||||
"lines_added": 884,
|
||||
"lines_deleted": 93,
|
||||
"binary_added": 1,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:07:26Z",
|
||||
"last_commit": "2026-10-09T06:04:40Z"
|
||||
"last_commit": "2026-10-09T18:24:49Z"
|
||||
},
|
||||
{
|
||||
"login": "voshart",
|
||||
@@ -85,6 +85,19 @@
|
||||
"first_commit": "2026-10-05T16:23:27Z",
|
||||
"last_commit": "2026-10-09T02:31:04Z"
|
||||
},
|
||||
{
|
||||
"login": "akvaithi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 5,
|
||||
"lines_added": 1799,
|
||||
"lines_deleted": 114,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:53:56Z",
|
||||
"last_commit": "2026-10-05T19:55:59Z"
|
||||
},
|
||||
{
|
||||
"login": "arata-1972",
|
||||
"people_entry": false,
|
||||
@@ -128,14 +141,14 @@
|
||||
"login": "Exalted5373",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 11,
|
||||
"lines_added": 1988,
|
||||
"lines_deleted": 132,
|
||||
"prs": 9,
|
||||
"commits": 15,
|
||||
"lines_added": 4316,
|
||||
"lines_deleted": 309,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T21:01:23Z",
|
||||
"last_commit": "2026-10-09T01:33:43Z"
|
||||
"last_commit": "2026-10-09T21:58:32Z"
|
||||
},
|
||||
{
|
||||
"login": "treyreynolds",
|
||||
@@ -258,14 +271,14 @@
|
||||
"login": "radialmonster",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 26,
|
||||
"commits": 53,
|
||||
"lines_added": 7227,
|
||||
"lines_deleted": 717,
|
||||
"prs": 35,
|
||||
"commits": 64,
|
||||
"lines_added": 8732,
|
||||
"lines_deleted": 822,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T12:33:51Z",
|
||||
"last_commit": "2026-10-09T11:31:46Z"
|
||||
"last_commit": "2026-10-10T02:01:36Z"
|
||||
},
|
||||
{
|
||||
"login": "bogusyogi",
|
||||
@@ -284,14 +297,14 @@
|
||||
"login": "nledford",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 88,
|
||||
"lines_added": 9299,
|
||||
"lines_deleted": 1024,
|
||||
"prs": 19,
|
||||
"commits": 305,
|
||||
"lines_added": 24796,
|
||||
"lines_deleted": 2896,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:33:38Z",
|
||||
"last_commit": "2026-10-09T00:59:12Z"
|
||||
"last_commit": "2026-10-09T23:05:22Z"
|
||||
},
|
||||
{
|
||||
"login": "MusseJusse",
|
||||
@@ -349,7 +362,7 @@
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"prs": 3,
|
||||
"commits": 2,
|
||||
"lines_added": 206,
|
||||
"lines_deleted": 42,
|
||||
@@ -544,14 +557,14 @@
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 6,
|
||||
"lines_added": 336,
|
||||
"lines_deleted": 29,
|
||||
"prs": 6,
|
||||
"commits": 7,
|
||||
"lines_added": 351,
|
||||
"lines_deleted": 36,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:47Z",
|
||||
"last_commit": "2026-10-09T08:45:45Z"
|
||||
"last_commit": "2026-10-09T17:29:39Z"
|
||||
},
|
||||
{
|
||||
"login": "peiyan0",
|
||||
@@ -635,14 +648,14 @@
|
||||
"login": "sanyamsingh001",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 431,
|
||||
"lines_deleted": 19,
|
||||
"prs": 4,
|
||||
"commits": 5,
|
||||
"lines_added": 841,
|
||||
"lines_deleted": 31,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T03:19:47Z",
|
||||
"last_commit": "2026-10-09T03:19:47Z"
|
||||
"last_commit": "2026-10-10T01:46:07Z"
|
||||
},
|
||||
{
|
||||
"login": "onemannoplan",
|
||||
@@ -674,40 +687,170 @@
|
||||
"login": "HarrisXiu",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 69,
|
||||
"lines_deleted": 1,
|
||||
"prs": 6,
|
||||
"commits": 6,
|
||||
"lines_added": 1247,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:34:51Z",
|
||||
"last_commit": "2026-10-09T09:34:51Z"
|
||||
"last_commit": "2026-10-10T05:46:24Z"
|
||||
},
|
||||
{
|
||||
"login": "rock-meister",
|
||||
"people_entry": true,
|
||||
"github_name": "rock-meister",
|
||||
"prs": 3,
|
||||
"commits": 4,
|
||||
"lines_added": 263,
|
||||
"lines_deleted": 44,
|
||||
"prs": 4,
|
||||
"commits": 5,
|
||||
"lines_added": 291,
|
||||
"lines_deleted": 46,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T10:15:52Z",
|
||||
"last_commit": "2026-10-09T11:26:47Z"
|
||||
"last_commit": "2026-10-09T15:00:07Z"
|
||||
},
|
||||
{
|
||||
"login": "QuirkyTurtle94",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 153,
|
||||
"lines_deleted": 39,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 475,
|
||||
"lines_deleted": 77,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T11:08:11Z",
|
||||
"last_commit": "2026-10-09T11:08:11Z"
|
||||
"last_commit": "2026-10-09T14:32:04Z"
|
||||
},
|
||||
{
|
||||
"login": "AER00",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 207,
|
||||
"lines_deleted": 21,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T12:48:11Z",
|
||||
"last_commit": "2026-10-09T13:57:38Z"
|
||||
},
|
||||
{
|
||||
"login": "AlexChambers",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 11,
|
||||
"commits": 16,
|
||||
"lines_added": 7202,
|
||||
"lines_deleted": 954,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T13:59:07Z",
|
||||
"last_commit": "2026-10-10T05:40:18Z"
|
||||
},
|
||||
{
|
||||
"login": "bitshiftza",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 342,
|
||||
"lines_deleted": 97,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T15:03:06Z",
|
||||
"last_commit": "2026-10-09T18:14:49Z"
|
||||
},
|
||||
{
|
||||
"login": "vbasky",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 622,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:43:31Z",
|
||||
"last_commit": "2026-10-09T17:43:31Z"
|
||||
},
|
||||
{
|
||||
"login": "kelanna",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1169,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:51:06Z",
|
||||
"last_commit": "2026-10-09T17:51:06Z"
|
||||
},
|
||||
{
|
||||
"login": "dmatviichuk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2292,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T18:02:42Z",
|
||||
"last_commit": "2026-10-09T18:02:42Z"
|
||||
},
|
||||
{
|
||||
"login": "GarrettGR",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 5,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T19:37:15Z",
|
||||
"last_commit": "2026-10-09T19:37:15Z"
|
||||
},
|
||||
{
|
||||
"login": "dylanmtaylor",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 38,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:04:24Z",
|
||||
"last_commit": "2026-10-10T00:04:24Z"
|
||||
},
|
||||
{
|
||||
"login": "sawizzle",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 23,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:06:24Z",
|
||||
"last_commit": "2026-10-10T04:50:51Z"
|
||||
},
|
||||
{
|
||||
"login": "KitsuneSemCalda",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 11,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T01:50:45Z",
|
||||
"last_commit": "2026-10-10T01:50:45Z"
|
||||
},
|
||||
{
|
||||
"login": "BuonBreda",
|
||||
@@ -735,6 +878,19 @@
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "enzoflayac",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "GuintherKovalski",
|
||||
"people_entry": false,
|
||||
@@ -761,6 +917,19 @@
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "pikkemaat",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "stano-bustor",
|
||||
"people_entry": false,
|
||||
@@ -794,9 +963,9 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 475,
|
||||
"lines_added": 147839,
|
||||
"lines_deleted": 10702
|
||||
"commits": 747,
|
||||
"lines_added": 178263,
|
||||
"lines_deleted": 14199
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
@@ -812,18 +981,18 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 65,
|
||||
"lines_added": 8385,
|
||||
"lines_deleted": 952
|
||||
"commits": 66,
|
||||
"lines_added": 8450,
|
||||
"lines_deleted": 956
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 33,
|
||||
"lines_added": 14418,
|
||||
"lines_deleted": 1647
|
||||
"commits": 38,
|
||||
"lines_added": 17120,
|
||||
"lines_deleted": 1703
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
@@ -870,6 +1039,15 @@
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt-6-luna",
|
||||
"company": "OpenAI",
|
||||
"model": "GPT-6 Luna",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 19
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt/6",
|
||||
"company": "OpenAI",
|
||||
@@ -878,6 +1056,15 @@
|
||||
"commits": 1,
|
||||
"lines_added": 2864,
|
||||
"lines_deleted": 261
|
||||
},
|
||||
{
|
||||
"id": "opencode/opencode",
|
||||
"company": "opencode",
|
||||
"model": "opencode",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 459,
|
||||
"lines_deleted": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -19,4 +20,5 @@ log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
lightcraft-geom = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
@@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
|
||||
/// A date without a time gets one. `None` if it doesn't parse.
|
||||
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
|
||||
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
|
||||
/// anything, it never overflows.
|
||||
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
|
||||
let t = iso_seconds(iso)?;
|
||||
let t = iso_seconds(iso)?.checked_add(secs)?;
|
||||
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
|
||||
if !(first..=last).contains(&t) {
|
||||
return None;
|
||||
}
|
||||
let tail = iso.trim().get(19..).unwrap_or("");
|
||||
Some(format!("{}{tail}", civil(t + secs)))
|
||||
Some(format!("{}{tail}", civil(t)))
|
||||
}
|
||||
|
||||
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
|
||||
@@ -204,6 +210,14 @@ mod tests {
|
||||
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
|
||||
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
|
||||
assert_eq!(shift_iso("nope", 5), None);
|
||||
// hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date
|
||||
// here can read back) is refused rather than stored
|
||||
for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] {
|
||||
assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}");
|
||||
}
|
||||
assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None);
|
||||
assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None);
|
||||
assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59"));
|
||||
assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00"));
|
||||
assert_eq!(normalize_iso("2026-02-30"), None);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,21 @@
|
||||
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
|
||||
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
|
||||
//!
|
||||
//! A folder is otherwise only where photos are, but the library can also keep a
|
||||
//! [`FolderRecord`] about it (its colour label), stored under the folder's identity
|
||||
//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved
|
||||
//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is
|
||||
//! kept (undo can bring the photos back) and shows again when the folder does.
|
||||
//!
|
||||
//! This is the library's own view of its photos; browsing any folder on disk without importing
|
||||
//! is Local's job (see [`crate::local`]).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::query::{folder_key, folder_within, split_path};
|
||||
use crate::{Catalog, Source};
|
||||
use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path};
|
||||
use crate::{Catalog, ColorLabel, Op, Source};
|
||||
|
||||
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
|
||||
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
|
||||
@@ -58,9 +64,35 @@ pub struct FolderNode {
|
||||
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
|
||||
/// path covers those disks' photos too, so choosing them is not offered.
|
||||
pub selectable: bool,
|
||||
/// The folder's colour label (see [`FolderRecord`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<ColorLabel>,
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
/// What the library keeps about one of its folders beyond the photos in it. A record with
|
||||
/// nothing in it is not kept.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FolderRecord {
|
||||
/// A colour label, as a photo has one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<ColorLabel>,
|
||||
}
|
||||
|
||||
impl FolderRecord {
|
||||
/// Whether the record says nothing (and so is not kept).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.label.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity a folder's record is kept under: its [`folder_key`]; `None` for a path that
|
||||
/// names no folder (empty, relative, `.`).
|
||||
pub(crate) fn record_key(path: &str) -> Option<String> {
|
||||
let key = folder_key(path);
|
||||
(split_path(path, true).absolute && !key.is_empty()).then_some(key)
|
||||
}
|
||||
|
||||
/// A folder while the tree is being built (children by identity key).
|
||||
struct Level {
|
||||
name: String,
|
||||
@@ -151,6 +183,53 @@ pub fn folder_label(path: &str) -> String {
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// What the library keeps about folder `path`, however it is spelled.
|
||||
pub fn folder_record(&self, path: &str) -> Option<&FolderRecord> {
|
||||
record_key(path).and_then(|k| self.folder_records.get(&k))
|
||||
}
|
||||
|
||||
/// The colour label of folder `path`, however it is spelled.
|
||||
pub fn folder_color_label(&self, path: &str) -> Option<ColorLabel> {
|
||||
self.folder_record(path).and_then(|r| r.label)
|
||||
}
|
||||
|
||||
/// The op that gives folder `path` colour label `label` (`None`: takes it off), keeping the
|
||||
/// rest of its record. Applying it checks the path.
|
||||
pub fn folder_label_op(&self, path: &str, label: Option<ColorLabel>) -> Op {
|
||||
let mut record = self.folder_record(path).cloned().unwrap_or_default();
|
||||
record.label = label;
|
||||
Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
|
||||
}
|
||||
|
||||
/// The ops that carry the records of folder `from` and the folders inside it over to `to`
|
||||
/// (the same place below it), for a folder renamed or moved on disk: commit them with the
|
||||
/// photos' relinks so undo puts both back. Records already at or below `to` are left from a
|
||||
/// folder that is no longer there and are dropped (undo restores them). Empty when there is
|
||||
/// nothing to carry or drop.
|
||||
pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec<Op> {
|
||||
let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
|
||||
if root == dest {
|
||||
return Vec::new();
|
||||
}
|
||||
let moved: Vec<(String, FolderRecord)> = self
|
||||
.folder_records
|
||||
.iter()
|
||||
.filter(|(k, _)| key_within(k, &root))
|
||||
.filter_map(|(k, r)| {
|
||||
let rest = folder_rest(k, &root)?;
|
||||
let base = dest.trim_end_matches('/');
|
||||
let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) };
|
||||
Some((folder_key(&path), r.clone()))
|
||||
})
|
||||
.collect();
|
||||
// what was known at the old place goes, and so does what is left at the new one (also
|
||||
// when the moved folder carries nothing: the folder now there is not the one it was about)
|
||||
let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest));
|
||||
let mut ops: Vec<Op> = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect();
|
||||
ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) }));
|
||||
ops
|
||||
}
|
||||
|
||||
/// The library's volumes with their folders and photo counts: volumes by name
|
||||
/// (case-insensitive), each folder with its subfolders the same way.
|
||||
pub fn folder_tree(&self) -> Vec<FolderNode> {
|
||||
@@ -196,7 +275,7 @@ impl Catalog {
|
||||
l.own += n;
|
||||
}
|
||||
}
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect();
|
||||
sort(&mut tree);
|
||||
// a folder that holds another disk's mount covers that disk's photos too
|
||||
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
|
||||
@@ -206,12 +285,21 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
|
||||
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
|
||||
fn build(key: &str, levels: &HashMap<String, Level>, records: &BTreeMap<String, FolderRecord>) -> Option<FolderNode> {
|
||||
let l = levels.get(key)?;
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels, records)).collect();
|
||||
sort(&mut children);
|
||||
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
|
||||
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
|
||||
Some(FolderNode {
|
||||
name: l.name.clone(),
|
||||
path: l.path.clone(),
|
||||
count,
|
||||
own: l.own,
|
||||
volume: l.volume,
|
||||
selectable: true,
|
||||
label: records.get(key).and_then(|r| r.label),
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
//! | 1 | up to v0.2.0 | |
|
||||
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
|
||||
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
|
||||
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
|
||||
//! | 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
|
||||
@@ -70,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 = 3;
|
||||
pub const VERSION: u32 = 8;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
+1250
-56
File diff suppressed because it is too large.
Load diff
+151
-4
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use folders::FolderNode;
|
||||
pub use folders::{FolderNode, FolderRecord};
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
@@ -52,6 +52,9 @@ pub enum CatalogError {
|
||||
NoStack(StackId),
|
||||
#[error("invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// A keyword of that name is there already: moving or renaming onto it would merge the two.
|
||||
#[error("there is a keyword “{0}” already")]
|
||||
KeywordExists(String),
|
||||
#[error("corrupt catalog data: {0}")]
|
||||
Corrupt(String),
|
||||
#[error("catalog storage: {0}")]
|
||||
@@ -122,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,
|
||||
},
|
||||
@@ -206,6 +215,18 @@ pub enum Op {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
preview_only: Option<String>,
|
||||
},
|
||||
/// The lens data a photo's file carries (what Reload finds when it was read after import).
|
||||
SetEmbeddedLens {
|
||||
id: PhotoId,
|
||||
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
|
||||
},
|
||||
/// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the
|
||||
/// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping,
|
||||
/// not an edit: the look does not change, only the As Shot readout.
|
||||
SetAsShotWb {
|
||||
id: PhotoId,
|
||||
wb: Option<(f64, f64)>,
|
||||
},
|
||||
/// The name shown for a colour label (`None` = its colour's name).
|
||||
SetLabelName {
|
||||
label: ColorLabel,
|
||||
@@ -217,6 +238,18 @@ pub enum Op {
|
||||
folder: String,
|
||||
at: Option<String>,
|
||||
},
|
||||
/// List a keyword in the library's keyword list with these attributes, or take it off the list
|
||||
/// (`None`; photos that carry it keep it). Format version 4.
|
||||
SetKeyword {
|
||||
path: String,
|
||||
info: Option<keywords::KeywordInfo>,
|
||||
},
|
||||
/// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under
|
||||
/// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing.
|
||||
SetFolderRecord {
|
||||
folder: String,
|
||||
record: Option<FolderRecord>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
@@ -239,6 +272,12 @@ pub struct Catalog {
|
||||
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
browsed: BTreeMap<String, String>,
|
||||
/// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
keyword_list: BTreeMap<String, keywords::ListedKeyword>,
|
||||
/// What the library keeps about its folders (folder identity → record), see [`folders`].
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
folder_records: BTreeMap<String, FolderRecord>,
|
||||
/// Increments on every applied op.
|
||||
#[serde(skip)]
|
||||
pub revision: u64,
|
||||
@@ -324,12 +363,57 @@ impl Catalog {
|
||||
/// no smart album).
|
||||
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self),
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
|
||||
Some(a) => a.photos.contains(&p.id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
|
||||
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
|
||||
/// search, they don't repeat it.
|
||||
pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool {
|
||||
let mut seen: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
|
||||
let mut next = vec![from];
|
||||
while let Some(a) = next.pop() {
|
||||
if !seen.insert(a) {
|
||||
continue;
|
||||
}
|
||||
let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
|
||||
if tested.contains(&to) {
|
||||
return true;
|
||||
}
|
||||
next.extend(tested);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// What is wrong with a saved smart album's rules now (`RuleSet::check`, after
|
||||
/// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty
|
||||
/// for a sound smart album, a plain album or no album.
|
||||
pub fn smart_album_problems(&self, id: AlbumId) -> Vec<rules::Problem> {
|
||||
let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
|
||||
let mut out: Vec<rules::Problem> = self.album_filter_problem(filter, Some(id)).into_iter().collect();
|
||||
if let Some(mut rules) = filter.rule_set.clone() {
|
||||
rules.upgrade();
|
||||
out.extend(rules.check_for(self, Some(id)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to
|
||||
/// itself, or to an album that leads back to it.
|
||||
pub fn album_filter_problem(&self, filter: &Filter, owner: Option<AlbumId>) -> Option<rules::Problem> {
|
||||
let (a, owner) = (filter.album?, owner?);
|
||||
(a == owner || self.album_reaches(a, owner)).then(|| rules::Problem {
|
||||
path: Vec::new(),
|
||||
field: Some("album".into()),
|
||||
issue: rules::Issue::AlbumLoop,
|
||||
message: format!("its album filter (album {}) would make this album include itself", a.0),
|
||||
})
|
||||
}
|
||||
|
||||
/// The photos of an album: the stored list, or a smart album's current matches (id order).
|
||||
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
|
||||
match self.albums.get(&id) {
|
||||
@@ -366,6 +450,18 @@ impl Catalog {
|
||||
pub fn label_name(&self, l: ColorLabel) -> String {
|
||||
self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}"))
|
||||
}
|
||||
/// A listed keyword's attributes (any case), `None` when it isn't listed.
|
||||
pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> {
|
||||
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info)
|
||||
}
|
||||
/// How the keyword list spells a listed keyword (any case), `None` when it isn't listed.
|
||||
pub fn listed_path(&self, path: &str) -> Option<&str> {
|
||||
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str())
|
||||
}
|
||||
/// The keyword list: keywords listed on their own or given attributes, by path.
|
||||
pub fn listed_keywords(&self) -> impl Iterator<Item = &keywords::ListedKeyword> {
|
||||
self.keyword_list.values()
|
||||
}
|
||||
/// The custom name of a colour label, if any.
|
||||
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
|
||||
self.label_names.get(&l).map(String::as_str)
|
||||
@@ -373,7 +469,9 @@ impl Catalog {
|
||||
/// The label a name stands for: a custom name first, then a colour's own name (any case).
|
||||
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
|
||||
let name = name.trim();
|
||||
self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
|
||||
// any case, accents included (Été / été), as rules compare names
|
||||
let name_lower = name.to_lowercase();
|
||||
self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
|
||||
}
|
||||
|
||||
// ---- writes
|
||||
@@ -453,12 +551,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) {
|
||||
@@ -596,6 +707,17 @@ impl Catalog {
|
||||
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
|
||||
}
|
||||
}
|
||||
Op::SetEmbeddedLens { id, lens } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
|
||||
}
|
||||
Op::SetAsShotWb { id, wb } => {
|
||||
if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {
|
||||
return Err(CatalogError::Invalid("as-shot white balance is not a number".into()));
|
||||
}
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) }
|
||||
}
|
||||
Op::SetFile { id, file_name, source } => {
|
||||
if file_name.trim().is_empty() {
|
||||
return Err(CatalogError::Invalid("empty file name".into()));
|
||||
@@ -612,6 +734,21 @@ impl Catalog {
|
||||
};
|
||||
Op::SetLabelName { label, name: old }
|
||||
}
|
||||
Op::SetKeyword { path, info } => {
|
||||
let path = keywords::clean(&path);
|
||||
if path.is_empty() {
|
||||
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
|
||||
}
|
||||
let key = path.to_lowercase();
|
||||
let old = match info {
|
||||
Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }),
|
||||
None => self.keyword_list.remove(&key),
|
||||
};
|
||||
match old {
|
||||
Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) },
|
||||
None => Op::SetKeyword { path, info: None },
|
||||
}
|
||||
}
|
||||
Op::SetBrowsed { folder, at } => {
|
||||
let folder = crate::query::folder_key(&folder);
|
||||
let old = match at {
|
||||
@@ -620,6 +757,14 @@ impl Catalog {
|
||||
};
|
||||
Op::SetBrowsed { folder, at: old }
|
||||
}
|
||||
Op::SetFolderRecord { folder, record } => {
|
||||
let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?;
|
||||
let old = match record.filter(|r| !r.is_empty()) {
|
||||
Some(r) => self.folder_records.insert(key.clone(), r),
|
||||
None => self.folder_records.remove(&key),
|
||||
};
|
||||
Op::SetFolderRecord { folder: key, record: old }
|
||||
}
|
||||
Op::Batch { ops } => {
|
||||
let mut inverses = Vec::with_capacity(ops.len());
|
||||
for op in ops {
|
||||
@@ -721,6 +866,8 @@ mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_folder_records;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
|
||||
@@ -279,11 +279,14 @@ impl Op {
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. }
|
||||
| Op::SetHistoryCursor { id, .. }
|
||||
| Op::SetCaptured { id, .. }
|
||||
| Op::SetAnalysis { id, .. }
|
||||
| Op::SetFile { id, .. }
|
||||
| Op::Relink { id, .. }
|
||||
| Op::SetContent { id, .. } => out(*id),
|
||||
| Op::SetContent { id, .. }
|
||||
| Op::SetEmbeddedLens { id, .. }
|
||||
| Op::SetAsShotWb { id, .. } => out(*id),
|
||||
Op::AddAlbum { album } => {
|
||||
album.photos.iter().for_each(|p| out(*p));
|
||||
album.cover.into_iter().for_each(&mut *out);
|
||||
@@ -299,7 +302,9 @@ impl Op {
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetBrowsed { .. } => {}
|
||||
| Op::SetKeyword { .. }
|
||||
| Op::SetBrowsed { .. }
|
||||
| Op::SetFolderRecord { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,15 @@
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
//!
|
||||
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
|
||||
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
|
||||
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
|
||||
//! that's actually held refuses the open.
|
||||
//! it is released explicitly when the [`LibraryLock`] is dropped and, after a crash or a kill, by
|
||||
//! the OS when the process ends. A `catalog.lock` file left behind is therefore never "stale":
|
||||
//! only a lock that's actually held refuses the open.
|
||||
//! - Dropping the lock calls [`std::fs::File::unlock`] rather than relying on the handle closing.
|
||||
//! On Unix an `flock` lock belongs to the open file description and lasts while *any* descriptor
|
||||
//! on it is open; a process that another thread is spawning holds a copy of every descriptor
|
||||
//! (`O_CLOEXEC` only closes them at `execve`), so closing ours would leave the library "already
|
||||
//! open" for a moment and a re-open would be refused (issue #680). `LOCK_UN` through our
|
||||
//! descriptor frees the lock whatever copies a child still holds.
|
||||
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
|
||||
//! the error message. It's a separate file because a Windows lock also blocks reading the
|
||||
//! locked file. It's only a hint: it may be missing or left over from a crash.
|
||||
@@ -87,7 +93,7 @@ pub enum LockError {
|
||||
pub struct LibraryLock {
|
||||
dir: PathBuf,
|
||||
/// `None` when the file system can't lock (the library is open unlocked).
|
||||
_file: Option<File>,
|
||||
file: Option<File>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
@@ -96,7 +102,7 @@ impl LibraryLock {
|
||||
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
|
||||
let unlocked = |why: io::Error| {
|
||||
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
|
||||
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
|
||||
LibraryLock { dir: dir.to_path_buf(), file: None, held: false }
|
||||
};
|
||||
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
|
||||
Ok(f) => f,
|
||||
@@ -116,7 +122,7 @@ impl LibraryLock {
|
||||
{
|
||||
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
|
||||
}
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), file: Some(file), held: true })
|
||||
}
|
||||
|
||||
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
|
||||
@@ -127,14 +133,30 @@ impl LibraryLock {
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// 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 (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())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LibraryLock {
|
||||
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
|
||||
/// then the file handle closes and the OS releases the lock.
|
||||
/// then release the lock explicitly. Closing the handle isn't enough on Unix: `flock` lasts
|
||||
/// while any copy of the descriptor is open, and a process another thread is spawning holds a
|
||||
/// copy of every descriptor until it execs (issue #680). If the unlock fails the handle still
|
||||
/// closes right after, which releases the lock once every copy is gone, as before.
|
||||
fn drop(&mut self) {
|
||||
if self.held {
|
||||
let _ = std::fs::remove_file(self.dir.join(OWNER));
|
||||
if let Some(f) = &self.file
|
||||
&& let Err(e) = f.unlock()
|
||||
{
|
||||
log::warn!("library {}: can't release {LOCK}: {e}", self.dir.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
-11
@@ -152,6 +152,17 @@ impl CopyrightStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for
|
||||
/// anything that isn't a positive, finite time.
|
||||
pub fn parse_shutter_seconds(s: &str) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
match s.split_once('/') {
|
||||
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
|
||||
None => s.parse().ok(),
|
||||
}
|
||||
.filter(|v: &f64| v.is_finite() && *v > 0.0)
|
||||
}
|
||||
|
||||
/// Descriptive + capture metadata.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -236,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,
|
||||
@@ -296,7 +312,33 @@ pub struct Analysis {
|
||||
pub best: bool,
|
||||
}
|
||||
|
||||
/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting
|
||||
/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same
|
||||
/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a
|
||||
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
|
||||
/// [`relative_wb_camera`].
|
||||
pub fn relative_wb_format(format: &str) -> bool {
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// 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, 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() && !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,
|
||||
@@ -317,6 +359,7 @@ impl Photo {
|
||||
edited: None,
|
||||
versions: Vec::new(),
|
||||
history: Vec::new(),
|
||||
history_cursor: None,
|
||||
deleted: false,
|
||||
local: false,
|
||||
duration: None,
|
||||
@@ -336,35 +379,88 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
/// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor
|
||||
/// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies'
|
||||
/// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats.
|
||||
pub fn fitted_look(&self) -> bool {
|
||||
self.develops_raw() && relative_wb_format(&self.format)
|
||||
}
|
||||
/// White balance relative to the camera's as-shot look, as for rendered photographs: a
|
||||
/// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either
|
||||
/// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin.
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw()
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera)
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
|
||||
/// in the photo's own rendering process.
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
match &self.import_look {
|
||||
Some(l) => (**l).clone(),
|
||||
Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() },
|
||||
None => self.camera_defaults(),
|
||||
}
|
||||
}
|
||||
/// The built-in defaults for this photo, before any user default preset: raws start from
|
||||
/// their as-shot white balance; embedded lens corrections on when the file has them.
|
||||
/// their as-shot white balance; embedded lens corrections on when the file has them. They
|
||||
/// carry the photo's own rendering process ([`lightcraft_develop::ProcessVersion`]): which
|
||||
/// process a photo renders with is not an edit, so comparisons with its defaults (edited?
|
||||
/// still as imported?) don't change when a newer process exists. A new photo has the latest.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let relative = self.relative_wb();
|
||||
let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let mut d = match wb {
|
||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||
None => DevelopSettings::default(),
|
||||
};
|
||||
if self.develops_raw() && !relative {
|
||||
d.wb.scale = lightcraft_develop::WbScale::Kelvin;
|
||||
}
|
||||
if self.embedded_lens.is_some() {
|
||||
d.optics.lens_profile = true;
|
||||
}
|
||||
d.process = self.develop.process;
|
||||
d
|
||||
}
|
||||
/// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty
|
||||
/// when the name has none (`README`, a dot file such as `.hidden`).
|
||||
pub fn extension(&self) -> &str {
|
||||
self.file_name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()).map_or("", |(_, ext)| ext)
|
||||
}
|
||||
/// Width and height in pixels as the photo is shown: turned by its orientation and cut by its
|
||||
/// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the
|
||||
/// aspect is exact and the edges can read a little larger than an export.
|
||||
pub fn shown_size(&self) -> (f64, f64) {
|
||||
let (w, h) = (self.width as f64, self.height as f64);
|
||||
let (w, h) = if self.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let r = self.develop.crop.geometry.rect;
|
||||
// a crop read from a file or an agent is hostile: no NaN, nothing outside the frame
|
||||
let part = |len: f64| if len.is_finite() { len.clamp(0.0, 1.0) } else { 1.0 };
|
||||
(w * part(r.width()), h * part(r.height()))
|
||||
}
|
||||
/// Cropped or straightened.
|
||||
pub fn is_cropped(&self) -> bool {
|
||||
!self.develop.crop.geometry.is_identity()
|
||||
}
|
||||
/// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and
|
||||
/// the same keyword in different case or with stray spaces counts once; blank ones don't count.
|
||||
pub fn keyword_count(&self) -> usize {
|
||||
let mut seen: Vec<String> = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
seen.len()
|
||||
}
|
||||
/// The people in the photo: the names on its face regions, trimmed, each once (names that
|
||||
/// differ only in case are one person, as first seen). Pets and unnamed faces are not people.
|
||||
pub fn people(&self) -> Vec<&str> {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
for r in self.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
|
||||
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// In the library: not deleted and not only browsed (Local).
|
||||
pub fn in_library(&self) -> bool {
|
||||
!self.deleted && !self.local
|
||||
@@ -424,6 +520,7 @@ impl Album {
|
||||
#[cfg(test)]
|
||||
mod edited_tests {
|
||||
use super::*;
|
||||
use lightcraft_develop::WbScale;
|
||||
|
||||
#[test]
|
||||
fn raw_with_import_settings_is_not_edited() {
|
||||
@@ -439,6 +536,32 @@ mod edited_tests {
|
||||
assert!(!p.is_edited(), "a full reset is unedited too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_process_a_photo_renders_with_is_not_an_edit() {
|
||||
use lightcraft_develop::ProcessVersion;
|
||||
// a photo on another process than the latest (a V1 photo once a later process is the
|
||||
// latest, or a photo saved by a newer LightCraft)
|
||||
let other = ProcessVersion(ProcessVersion::LATEST.0 + 1);
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.dng", "DNG", 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
assert_eq!(p.camera_defaults().process, ProcessVersion::LATEST, "a new photo gets the latest");
|
||||
p.develop = Arc::new(DevelopSettings { process: other, ..p.import_defaults() });
|
||||
assert_eq!((p.camera_defaults().process, p.import_defaults().process), (other, other));
|
||||
assert!(!p.is_edited(), "still as imported");
|
||||
// with a default preset's look as well
|
||||
let mut look = DevelopSettings::for_raw(5200.0, 4.0);
|
||||
look.light.exposure = 0.3;
|
||||
p.import_look = Some(Arc::new(look.clone()));
|
||||
p.develop = Arc::new(DevelopSettings { process: other, ..look });
|
||||
assert_eq!(p.import_defaults().process, other);
|
||||
assert!(!p.is_edited());
|
||||
let mut d = (*p.develop).clone();
|
||||
d.light.exposure = 1.0;
|
||||
p.develop = Arc::new(d);
|
||||
assert!(p.is_edited(), "an edit still is one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_raws_use_relative_white_balance() {
|
||||
for (name, format, relative) in [
|
||||
@@ -451,14 +574,20 @@ mod edited_tests {
|
||||
("a.raf", "rAf", true),
|
||||
("a.cr3", "cr3", true),
|
||||
("a.srw", "SRW", true),
|
||||
("a.cr2", "CR2", true),
|
||||
("a.pef", "pef", true),
|
||||
("a.orf", "ORF", true),
|
||||
("a.dng", "DNG", false),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = "Pentax K-1".into();
|
||||
p.as_shot_wb = Some((5200.0, 4.0));
|
||||
assert_eq!(p.relative_wb(), relative, "{format}");
|
||||
assert_eq!(p.fitted_look(), relative, "{format}");
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
|
||||
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}");
|
||||
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
|
||||
let mut custom = (*p.develop).clone();
|
||||
@@ -467,7 +596,45 @@ mod edited_tests {
|
||||
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
|
||||
// shown from the embedded preview: a rendered image, not a relative-WB raw
|
||||
p.preview_only = Some("unsupported".into());
|
||||
assert!(!p.relative_wb());
|
||||
assert!(!p.relative_wb() && !p.fitted_look());
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730):
|
||||
/// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on.
|
||||
#[test]
|
||||
fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() {
|
||||
for (camera, format, relative) in [
|
||||
("SONY ILCE-7M3", "ARW", false),
|
||||
("SONY ILCE-7RM4A", "ARW", false),
|
||||
("NIKON CORPORATION NIKON D850", "NEF", false),
|
||||
("Canon Canon EOS 5D Mark III", "CR2", false),
|
||||
("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),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.meta.camera = camera.into();
|
||||
p.as_shot_wb = Some((4986.0, -2.0));
|
||||
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
|
||||
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
|
||||
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
|
||||
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
|
||||
// a photo from an older catalog: stored As Shot on the relative scale, still not an edit
|
||||
p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0));
|
||||
assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit");
|
||||
// its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot
|
||||
p.as_shot_wb = Some((6500.0, 0.0));
|
||||
assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot);
|
||||
assert!(!p.is_edited());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -154,12 +156,21 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
/// Whether `p` has a named face region called `name` (case-insensitive).
|
||||
fn has_person(p: &Photo, name: &str) -> bool {
|
||||
let name = name.trim().to_lowercase();
|
||||
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
|
||||
p.people().iter().any(|n| n.to_lowercase() == name)
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
|
||||
pub fn describe(&self) -> String {
|
||||
self.describe_in(None)
|
||||
}
|
||||
|
||||
/// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`).
|
||||
pub fn describe_with(&self, cat: &crate::Catalog) -> String {
|
||||
self.describe_in(Some(cat))
|
||||
}
|
||||
|
||||
fn describe_in(&self, cat: Option<&crate::Catalog>) -> String {
|
||||
let mut v: Vec<String> = Vec::new();
|
||||
if self.rating > 0 {
|
||||
let op = match self.rating_op {
|
||||
@@ -207,11 +218,18 @@ impl Filter {
|
||||
v.push(format!("“{}”", self.text.trim()));
|
||||
}
|
||||
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
v.push(rs.describe());
|
||||
v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c)));
|
||||
}
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
/// The albums this filter tests: its album field and its rules' Album rules.
|
||||
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
|
||||
let mut out: Vec<crate::AlbumId> = self.album.into_iter().collect();
|
||||
out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock (only "in the last…" rules do).
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
|
||||
@@ -343,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();
|
||||
@@ -353,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() }
|
||||
});
|
||||
|
||||
+1219
-69
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::query::cmp_name;
|
||||
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
|
||||
|
||||
impl Catalog {
|
||||
@@ -203,17 +204,19 @@ impl Catalog {
|
||||
/// Only runs of two or more are returned.
|
||||
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
|
||||
let index = self.stack_index();
|
||||
let mut timed: Vec<(i64, PhotoId)> = ids
|
||||
let mut timed: Vec<(i64, &str, PhotoId)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| self.photos.get(id))
|
||||
.filter(|p| !p.deleted && !index.contains_key(&p.id))
|
||||
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id)))
|
||||
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id)))
|
||||
.collect();
|
||||
timed.sort();
|
||||
timed.dedup_by_key(|(_, id)| *id);
|
||||
// a burst within one second stays in file name order (the first file is the stack's cover)
|
||||
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
|
||||
// equal ids are the same photo, so same time and name: adjacent after the sort
|
||||
timed.dedup_by_key(|(_, _, id)| *id);
|
||||
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
|
||||
let mut last: Option<i64> = None;
|
||||
for (t, id) in timed {
|
||||
for (t, _, id) in timed {
|
||||
match (last, groups.last_mut()) {
|
||||
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
|
||||
_ => groups.push(vec![id]),
|
||||
@@ -264,6 +267,22 @@ mod tests {
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_stack_keeps_a_burst_in_file_order() {
|
||||
let mut c = Catalog::new();
|
||||
let mut by_name = Vec::new();
|
||||
for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() {
|
||||
let id = PhotoId(1000 - n as u64);
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
|
||||
p.captured = Some("2026-04-01T10:00:00".into());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
by_name.push(id);
|
||||
}
|
||||
let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0);
|
||||
assert_eq!(groups, vec![by_name.clone()]);
|
||||
assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_seconds_parses() {
|
||||
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
|
||||
|
||||
@@ -457,6 +457,199 @@ fn random_sort_has_no_date_headers() {
|
||||
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
|
||||
}
|
||||
|
||||
/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back.
|
||||
#[test]
|
||||
fn set_embedded_lens_is_undoable_and_journaled() {
|
||||
let mut c = Catalog::default();
|
||||
let a = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
|
||||
})
|
||||
.unwrap();
|
||||
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };
|
||||
let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
|
||||
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens));
|
||||
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
|
||||
}
|
||||
|
||||
/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the
|
||||
/// journal's JSON) takes it back, and a value that is not a number is refused.
|
||||
#[test]
|
||||
fn set_as_shot_wb_is_undoable_journaled_and_checked() {
|
||||
let mut c = Catalog::default();
|
||||
let a = c.alloc_photo_id();
|
||||
let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00");
|
||||
p.as_shot_wb = Some((6500.0, 0.0));
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) };
|
||||
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0)));
|
||||
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
|
||||
assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err());
|
||||
assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err());
|
||||
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
|
||||
}
|
||||
|
||||
/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
|
||||
/// with its problems; a good one has none; an old album operator is read as it was meant.
|
||||
#[test]
|
||||
fn smart_album_problems_flag_stale_rules() {
|
||||
let mut c = Catalog::new();
|
||||
c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
|
||||
let smart = |id: u64, rules: serde_json::Value| {
|
||||
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap();
|
||||
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") }
|
||||
};
|
||||
c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap();
|
||||
c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap();
|
||||
assert!(c.smart_album_problems(AlbumId(2)).is_empty());
|
||||
assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem");
|
||||
assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules");
|
||||
c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap();
|
||||
let p = c.smart_album_problems(AlbumId(2));
|
||||
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 1: no album 1".to_string()]);
|
||||
}
|
||||
|
||||
/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded
|
||||
/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they
|
||||
/// change.
|
||||
#[test]
|
||||
fn a_smart_album_can_exclude_another() {
|
||||
let mut c = Catalog::new();
|
||||
let smart = |id: u64, name: &str, rules: serde_json::Value| {
|
||||
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap();
|
||||
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) }
|
||||
};
|
||||
c.apply(Op::AddAlbum {
|
||||
album: smart(
|
||||
1,
|
||||
"Excluded Photos",
|
||||
serde_json::json!({"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}),
|
||||
),
|
||||
})
|
||||
.unwrap();
|
||||
let travel = |c: &mut Catalog, name: &str, label: Option<ColorLabel>, flag: Flag| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
|
||||
p.meta.keywords = vec!["travel".into()];
|
||||
p.label = label;
|
||||
p.flag = flag;
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let keep = travel(&mut c, "keep.jpg", None, Flag::Pick);
|
||||
let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None);
|
||||
let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject);
|
||||
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
|
||||
{"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]}))
|
||||
.unwrap();
|
||||
assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c));
|
||||
let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::<Vec<_>>();
|
||||
assert_eq!(matched(&c), vec![keep]);
|
||||
// "is" works the other way round
|
||||
let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap();
|
||||
assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2);
|
||||
let _ = (red, rejected);
|
||||
}
|
||||
|
||||
/// An album can't include itself, directly or through other smart albums: the check refuses the
|
||||
/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file)
|
||||
/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no
|
||||
/// photos, and every album in it is reported.
|
||||
#[test]
|
||||
fn smart_album_loops_are_refused_and_survived() {
|
||||
let mut c = Catalog::new();
|
||||
let refers = |to: u64| -> Filter {
|
||||
serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap()
|
||||
};
|
||||
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
|
||||
// A → B → C
|
||||
c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap();
|
||||
c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap();
|
||||
c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap();
|
||||
let rules = |to: u64| refers(to).rule_set.unwrap();
|
||||
assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A");
|
||||
assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself");
|
||||
assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet");
|
||||
assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop");
|
||||
assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3)));
|
||||
// a loop saved anyway: C → A
|
||||
c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap();
|
||||
let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00");
|
||||
let p = c.photo(id).unwrap().clone();
|
||||
for a in [1, 2, 3] {
|
||||
let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow
|
||||
assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported");
|
||||
}
|
||||
}
|
||||
|
||||
/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids;
|
||||
/// one that is gone shows as its number.
|
||||
#[test]
|
||||
fn summaries_name_albums() {
|
||||
let mut c = Catalog::new();
|
||||
c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap();
|
||||
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
|
||||
{"field": "keywords", "op": "contains", "value": "travel"},
|
||||
{"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]}))
|
||||
.unwrap();
|
||||
assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)");
|
||||
let f = Filter { rule_set: Some(rules), ..Default::default() };
|
||||
assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”"));
|
||||
}
|
||||
|
||||
/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is
|
||||
/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once
|
||||
/// per question. 25 albums deep stays instant.
|
||||
#[test]
|
||||
fn chains_of_smart_albums_stay_linear() {
|
||||
let mut c = Catalog::new();
|
||||
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") };
|
||||
c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap();
|
||||
for k in 2..=26u64 {
|
||||
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [
|
||||
{"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}}))
|
||||
.unwrap();
|
||||
c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap();
|
||||
}
|
||||
for i in 0..20 {
|
||||
photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00");
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(c.album_count(AlbumId(26)), 20);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is
|
||||
/// reported, and so is A filtered to B while B's rules test A.
|
||||
#[test]
|
||||
fn an_album_filter_counts_in_loops() {
|
||||
let mut c = Catalog::new();
|
||||
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
|
||||
c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap();
|
||||
let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop);
|
||||
assert!(loops(&c, 1), "filtered to itself");
|
||||
let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap();
|
||||
c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3)));
|
||||
assert!(loops(&c, 2) && loops(&c, 3));
|
||||
let _ = tests_3;
|
||||
let shown: Vec<String> = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect();
|
||||
assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undated_photos_group_under_unknown_date_and_sort_together() {
|
||||
let mut c = Catalog::new();
|
||||
@@ -501,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);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! What the library keeps about its folders: a colour label, like a photo's (see
|
||||
//! [`crate::folders::FolderRecord`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone organising their library by folder:
|
||||
//!
|
||||
//! * Given a folder of the library, when I give it a colour label, its row in the folder tree
|
||||
//! shows that label; the folders around it show none.
|
||||
//! * However the folder is spelled, it is one folder with one label.
|
||||
//! * When I take the label off, the library keeps nothing about the folder.
|
||||
//! * Every change is one undo step: undo puts back the label the folder had before.
|
||||
//! * A path that names no folder is refused and nothing changes.
|
||||
//! * When the folder is renamed or moved, its label and those of the folders inside it go along;
|
||||
//! a folder beside it whose name merely starts the same way keeps its own. Undo brings them
|
||||
//! back, also a label that was left at the destination.
|
||||
//! * Labels survive closing and reopening the library.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, path: &str) {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
|
||||
fn library() -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for p in ["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip2/c.jpg", "/pics/home/d.jpg"] {
|
||||
add(&mut c, p);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn label(c: &mut Catalog, path: &str, l: Option<ColorLabel>) -> Op {
|
||||
let op = c.folder_label_op(path, l);
|
||||
c.apply(op).unwrap()
|
||||
}
|
||||
|
||||
/// The label of each row of the tree, by path, depth first (rows without one left out).
|
||||
fn tree_labels(c: &Catalog) -> Vec<(String, ColorLabel)> {
|
||||
fn walk(n: &FolderNode, out: &mut Vec<(String, ColorLabel)>) {
|
||||
if let Some(l) = n.label {
|
||||
out.push((n.path.clone(), l));
|
||||
}
|
||||
n.children.iter().for_each(|c| walk(c, out));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
c.folder_tree().iter().for_each(|n| walk(n, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_library_knows_nothing_about_its_folders() {
|
||||
let c = library();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), None);
|
||||
assert!(tree_labels(&c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_labelled_folder_shows_its_label_in_the_tree_and_only_there() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Red));
|
||||
assert_eq!(tree_labels(&c), vec![("/pics/trip".to_string(), ColorLabel::Red)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn however_a_folder_is_spelled_it_has_one_label() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics//trip/", Some(ColorLabel::Green));
|
||||
assert_eq!(c.folder_color_label("/pics/./trip"), Some(ColorLabel::Green));
|
||||
label(&mut c, "/pics/home/../trip", Some(ColorLabel::Blue));
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Blue));
|
||||
assert_eq!(tree_labels(&c).len(), 1, "one folder, one record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_the_label_off_leaves_no_record() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Purple));
|
||||
label(&mut c, "/pics/trip", None);
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
assert!(!c.to_snapshot().contains("folder_records"), "nothing empty is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_record_is_no_record() {
|
||||
let mut c = library();
|
||||
c.apply(Op::SetFolderRecord { folder: "/pics/trip".into(), record: Some(FolderRecord::default()) }).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_puts_back_the_label_the_folder_had() {
|
||||
let mut c = library();
|
||||
let undo_first = label(&mut c, "/pics/trip", Some(ColorLabel::Yellow));
|
||||
let undo_second = label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
c.apply(undo_second).unwrap();
|
||||
assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
|
||||
c.apply(undo_first).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_is_refused() {
|
||||
let mut c = library();
|
||||
let before = c.to_snapshot();
|
||||
for bad in ["", "relative/trip", ".", "a/.."] {
|
||||
let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) };
|
||||
assert!(c.apply(op).is_err(), "{bad:?} names no folder");
|
||||
}
|
||||
assert_eq!(c.to_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_folder_takes_its_labels_and_its_subfolders_labels_along() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green));
|
||||
label(&mut c, "/pics/trip2", Some(ColorLabel::Blue));
|
||||
let ops = c.folder_records_follow("/pics/trip", "/archive/2026/trip");
|
||||
c.apply(Op::Batch { ops }).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/trip"), None);
|
||||
assert_eq!(c.folder_color_label("/archive/2026/trip"), Some(ColorLabel::Red));
|
||||
assert_eq!(c.folder_color_label("/archive/2026/trip/day1"), Some(ColorLabel::Green));
|
||||
assert_eq!(c.folder_color_label("/pics/trip2"), Some(ColorLabel::Blue), "a neighbour that only starts the same stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_move_brings_back_every_label_also_one_left_at_the_destination() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/trip", Some(ColorLabel::Red));
|
||||
// a folder that once was at the destination left its labels behind
|
||||
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
|
||||
label(&mut c, "/pics/holiday/day9", Some(ColorLabel::Yellow));
|
||||
let before = c.to_snapshot();
|
||||
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
|
||||
let undo = c.apply(Op::Batch { ops }).unwrap();
|
||||
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Red), "the moved folder's label wins");
|
||||
assert_eq!(c.folder_record("/pics/holiday/day9"), None, "what was known about the old folder goes");
|
||||
c.apply(undo).unwrap();
|
||||
assert_eq!(c.to_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_without_labels_moves_without_ops() {
|
||||
let c = library();
|
||||
assert!(c.folder_records_follow("/pics/trip", "/elsewhere/trip").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() {
|
||||
let mut c = library();
|
||||
// a folder that was at the destination once (its photos are gone) left its label behind
|
||||
label(&mut c, "/pics/holiday", Some(ColorLabel::Purple));
|
||||
let ops = c.folder_records_follow("/pics/trip", "/pics/holiday");
|
||||
let undo = c.apply(Op::Batch { ops }).unwrap();
|
||||
assert_eq!(c.folder_record("/pics/holiday"), None, "the folder now there is another one");
|
||||
c.apply(undo).unwrap();
|
||||
assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_survive_the_journal_without_a_snapshot() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
{
|
||||
let p = "/pics/trip/a.jpg";
|
||||
let id = c.alloc_photo_id();
|
||||
let op = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: p.into() }, "a.jpg", "JPEG", 6, 4, "2026-01-01")) };
|
||||
c.apply(op.clone()).unwrap();
|
||||
j.append(std::slice::from_ref(&op)).unwrap();
|
||||
}
|
||||
let op = c.folder_label_op("/pics/trip", Some(ColorLabel::Yellow));
|
||||
c.apply(op.clone()).unwrap();
|
||||
j.append(std::slice::from_ref(&op)).unwrap();
|
||||
drop(j);
|
||||
let (_, reopened, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert!(r.replayed >= 2, "{r:?}");
|
||||
assert_eq!(reopened.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_survive_closing_and_reopening_the_library() {
|
||||
let mut c = library();
|
||||
label(&mut c, "/pics/home", Some(ColorLabel::Green));
|
||||
let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
|
||||
assert_eq!(reopened.folder_color_label("/pics/home"), Some(ColorLabel::Green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_saved_before_folder_records_opens_without_any() {
|
||||
let c = library();
|
||||
let old = c.to_snapshot();
|
||||
assert!(!old.contains("folder_records"));
|
||||
let reopened = Catalog::from_snapshot(&old).unwrap();
|
||||
assert_eq!(reopened.folder_record("/pics/trip"), None);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ fn v1_library_loads_and_is_upgraded() {
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, None));
|
||||
}
|
||||
|
||||
/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3.
|
||||
/// A library saved before albums could be ordered by hand opens as it was and is rewritten in the current format.
|
||||
#[test]
|
||||
fn v2_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
@@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() {
|
||||
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(2)));
|
||||
assert_eq!(snapshot_version(&m), 3);
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
/// A library saved before keywords could be listed on their own opens as it was and is rewritten
|
||||
/// in the current format.
|
||||
#[test]
|
||||
fn v3_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(3)));
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,8 +207,13 @@ fn op_variants_are_versioned() {
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
Op::SetAlbumOrder { .. } => 3,
|
||||
Op::SetEmbeddedLens { .. } => 4,
|
||||
Op::SetKeyword { .. } => 5,
|
||||
Op::SetFolderRecord { .. } => 6,
|
||||
Op::SetAsShotWb { .. } => 7,
|
||||
Op::SetHistoryCursor { .. } => 8,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: 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");
|
||||
}
|
||||
@@ -78,6 +78,15 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
|
||||
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
|
||||
9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) },
|
||||
9 if b % 7 == 5 => Op::SetKeyword {
|
||||
path: format!("travel|place {}", b % 4),
|
||||
info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo {
|
||||
person: a.is_multiple_of(2),
|
||||
synonyms: vec![format!("trip {a}")],
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
9 if b % 11 == 4 => c.folder_label_op(&format!("/p/f{}", a % 4), ColorLabel::ALL.get(usize::from(b % 6)).copied()),
|
||||
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
|
||||
9 if b.is_multiple_of(2) => {
|
||||
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }
|
||||
@@ -326,3 +335,44 @@ fn failed_streamed_write_keeps_the_old_file() {
|
||||
assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A damaged rendering process in a saved photo (snapshot or log) reads as V1 and costs nothing
|
||||
/// else: the photo and its edits load (`docs/process-versions.md`).
|
||||
#[test]
|
||||
fn a_damaged_process_value_loads_as_v1_with_the_edits() {
|
||||
use lightcraft_develop::ProcessVersion;
|
||||
let mut c = Catalog::new();
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00");
|
||||
p.develop = Arc::new(DevelopSettings {
|
||||
process: ProcessVersion(7),
|
||||
light: lightcraft_develop::Light { exposure: 0.5, ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let snapshot = c.to_snapshot();
|
||||
assert!(snapshot.contains(r#""process":7"#));
|
||||
let snapshot = format!(
|
||||
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":0,\"catalog\":{}}}\n",
|
||||
crate::journal::VERSION,
|
||||
snapshot.replace(r#""process":7"#, r#""process":-1"#)
|
||||
);
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snapshot.into_bytes());
|
||||
let (_, loaded, _) = open(&m);
|
||||
let d = &loaded.photo(id).unwrap().develop;
|
||||
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5));
|
||||
// and in a (CRC-valid) log record
|
||||
let edit = DevelopSettings {
|
||||
process: ProcessVersion(7),
|
||||
light: lightcraft_develop::Light { exposure: 0.8, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let op = Op::SetDevelop { id, settings: Arc::new(edit), label: "Edit".into(), edited: None };
|
||||
let body = serde_json::to_string(&op).unwrap().replace(r#""process":7"#, r#""process":"seven""#);
|
||||
assert!(body.contains("seven"));
|
||||
m.set(LOG, format!("{{\"seq\":1,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
|
||||
let (_, loaded, _) = open(&m);
|
||||
let d = &loaded.photo(id).unwrap().develop;
|
||||
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
|
||||
}
|
||||
@@ -5,6 +5,17 @@ use std::path::PathBuf;
|
||||
use crate::lock::{LOCK, OWNER};
|
||||
use crate::*;
|
||||
|
||||
/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of
|
||||
/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file
|
||||
/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock
|
||||
/// another test drops in that moment stays held by the copy, so acquiring it again could fail with
|
||||
/// "in use".
|
||||
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
@@ -14,6 +25,7 @@ fn temp_dir(tag: &str) -> PathBuf {
|
||||
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let _serial = serial();
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(first.held());
|
||||
@@ -38,6 +50,7 @@ fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
|
||||
#[test]
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let _serial = serial();
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
@@ -52,6 +65,7 @@ fn leftover_lock_files_are_not_a_lock() {
|
||||
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
|
||||
#[test]
|
||||
fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
let _serial = serial();
|
||||
let dir = temp_dir("child");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
@@ -91,3 +105,68 @@ fn lock_holder_child() {
|
||||
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(120));
|
||||
}
|
||||
|
||||
/// Issue #680: a process spawned by any thread holds a copy of every descriptor until it execs
|
||||
/// (close-on-exec only closes them then), and `flock` lasts while any copy of the descriptor is
|
||||
/// open. Dropping the `LibraryLock` must still free the library, so it releases explicitly rather
|
||||
/// than relying on its handle closing. A `try_clone` of the descriptor stands in for the child's
|
||||
/// copy: it refers to the same open file description, which is exactly what the child gets.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
|
||||
let dir = temp_dir("copy");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
let copy = first.descriptor_copy().unwrap();
|
||||
drop(first);
|
||||
let again = LibraryLock::acquire(&dir, "lightcraft-cli").expect("the library is free once its LibraryLock is dropped");
|
||||
assert!(again.held());
|
||||
drop((again, copy));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
|
||||
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
|
||||
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let dir = temp_dir("spawn");
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let spawner = {
|
||||
let stop = Arc::clone(&stop);
|
||||
std::thread::spawn(move || {
|
||||
let mut spawned = 0usize;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
spawned
|
||||
})
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let mut cycles = 0usize;
|
||||
let mut refused = Vec::new();
|
||||
while cycles < 200_000 && std::time::Instant::now() < deadline {
|
||||
match LibraryLock::acquire(&dir, "LightCraft") {
|
||||
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
|
||||
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
|
||||
}
|
||||
cycles += 1;
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let spawned = spawner.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(spawned > 0, "the spawning thread never got a child off the ground");
|
||||
assert!(
|
||||
refused.is_empty(),
|
||||
"{} of {cycles} re-acquires refused while {spawned} children were spawned: {:?}",
|
||||
refused.len(),
|
||||
&refused[..refused.len().min(3)]
|
||||
);
|
||||
}
|
||||
@@ -30,17 +30,26 @@ jxl-oxide = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
ravif = { workspace = true, optional = true }
|
||||
rav1e = { workspace = true, optional = true }
|
||||
avif-serialize = { workspace = true, optional = true }
|
||||
# spawns threads of its own (std::thread), which wasm32 has not got
|
||||
jixel = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
|
||||
jxl = ["dep:jxl-oxide", "dep:jixel"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
|
||||
heif = ["dep:lightcraft-heif"]
|
||||
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
|
||||
avif = ["dep:ravif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Lossless synthetic HEIC files for the `heif` feature's tests.
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
+18
-7
@@ -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
|
||||
@@ -23,6 +23,13 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
|
||||
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
|
||||
encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
|
||||
|
||||
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
|
||||
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
|
||||
gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?;
|
||||
let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's
|
||||
gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?;
|
||||
```
|
||||
|
||||
Thumbnail fallback decoding limits the source to 64 million pixels by default.
|
||||
@@ -45,9 +52,10 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
|
||||
| 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) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
|
||||
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
|
||||
| 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_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 |
|
||||
|
||||
## Colour
|
||||
|
||||
@@ -80,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
|
||||
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
|
||||
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
|
||||
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
|
||||
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
|
||||
|
||||
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
|
||||
@@ -100,9 +109,11 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap
|
||||
|
||||
## Gaps / limitations
|
||||
|
||||
- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
|
||||
(dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
|
||||
`Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
|
||||
- **AVIF decode**: no permissively licensed, pure-Rust AV1 decoder exists today (dav1d is C;
|
||||
rav1d-safe is AGPL). Files are sniffed and rejected with `Error::Unsupported`.
|
||||
- **HEIC** needs the `heif` feature (off by default: HEVC patents are the distributor's call);
|
||||
without it, `Error::Unsupported` says the build has no HEIC support. 4:2:2 and 4:4:4 HEIF
|
||||
(Canon/Sony HIF) is refused: heic-rs 0.1.1 loses sync on those bitstreams.
|
||||
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
|
||||
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
|
||||
baseline) for maximum compatibility.
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
//! Monitor (display) ICC profiles: showing photos correctly on wide-gamut and calibrated displays.
|
||||
//!
|
||||
//! The loupe renders straight into the display's own primaries: the develop pipeline targets the
|
||||
//! display's linear RGB ([`DisplayProfile::to_rec2020`]) and encodes it with the sRGB curve, as it
|
||||
//! does for every 8-bit output. That "proxy" encoding then goes to the display's real device
|
||||
//! values with [`DisplayProfile::correct`]: its tone curves for a matrix/TRC profile (a per-channel
|
||||
//! table), or its LUTs for a LUT-based profile (a CMS transform). Images made for sRGB (thumbnails,
|
||||
//! cached previews, embedded camera JPEGs) go through [`DisplayProfile::from_srgb`] instead.
|
||||
//!
|
||||
//! All conversions are relative colorimetric: the display's white is the image's white.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::icc;
|
||||
use crate::space::Trc;
|
||||
use lightcraft_color::{D50, D65, Mat3, REC2020, bradford};
|
||||
use lightcraft_raster::Rgba8;
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions, Xyzd};
|
||||
|
||||
/// Largest profile file accepted (monitor profiles are a few KB; LUT profiles up to a few MB).
|
||||
pub const MAX_PROFILE_BYTES: usize = 32 << 20;
|
||||
|
||||
/// How a display profile describes the display.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayKind {
|
||||
/// Primaries + tone curves (exact).
|
||||
MatrixTrc,
|
||||
/// LUT-based (A2B/B2A tables): applied through the CMS; the primaries the pipeline targets are
|
||||
/// the profile's matrix tags when it has them, else measured through its tables.
|
||||
Lut,
|
||||
}
|
||||
|
||||
impl DisplayKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
DisplayKind::MatrixTrc => "matrix",
|
||||
DisplayKind::Lut => "lut",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy → device step of a display.
|
||||
enum Correction {
|
||||
/// The proxy encoding is the display's (sRGB-curve matrix profile): nothing to do.
|
||||
None,
|
||||
/// Per-channel tables (matrix/TRC profile: same primaries, other curves).
|
||||
Curves(Box<[[u8; 256]; 3]>),
|
||||
/// A full CMS transform (LUT-based profile).
|
||||
Cms(Arc<Transform8BitExecutor>),
|
||||
}
|
||||
|
||||
/// A parsed display profile with its transforms (built once, applied to every preview).
|
||||
pub struct DisplayProfile {
|
||||
/// The profile's own description (or "Display profile").
|
||||
pub description: String,
|
||||
/// Identifies the profile (hash of its bytes): part of render keys.
|
||||
pub id: u64,
|
||||
pub kind: DisplayKind,
|
||||
/// Linear display RGB → linear Rec.2020 (D65; display white → (1, 1, 1)).
|
||||
pub to_rec2020: Mat3,
|
||||
correction: Correction,
|
||||
from_srgb: Arc<Transform8BitExecutor>,
|
||||
to_srgb: Arc<Transform8BitExecutor>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DisplayProfile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DisplayProfile")
|
||||
.field("description", &self.description)
|
||||
.field("id", &self.id)
|
||||
.field("kind", &self.kind)
|
||||
.field("to_rec2020", &self.to_rec2020)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn opts() -> TransformOptions {
|
||||
TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() }
|
||||
}
|
||||
|
||||
fn xyzd(v: [f64; 3]) -> Xyzd {
|
||||
Xyzd { x: v[0], y: v[1], z: v[2] }
|
||||
}
|
||||
|
||||
/// Linear Rec.2020 → XYZ relative to D50 (the ICC PCS), Bradford-adapted.
|
||||
fn rec2020_to_xyz_d50() -> Mat3 {
|
||||
bradford(D65, D50).mul(&REC2020.to_xyz())
|
||||
}
|
||||
|
||||
/// Scale `m`'s columns so that (1, 1, 1) maps to (1, 1, 1): the display's white is the image's.
|
||||
fn white_balanced(m: Mat3) -> Option<Mat3> {
|
||||
let s = m.inverse()?.apply([1.0; 3]);
|
||||
s.iter().all(|v| v.is_finite() && *v > 0.0).then(|| m.mul(&Mat3::diag(s[0], s[1], s[2])))
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
bytes.hash(&mut h);
|
||||
h.finish() | 1
|
||||
}
|
||||
|
||||
/// Runs a moxcms call that may panic on a hostile profile.
|
||||
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
|
||||
}
|
||||
|
||||
impl DisplayProfile {
|
||||
/// Parse a display profile (an RGB ICC profile, matrix/TRC or LUT-based) and build its
|
||||
/// transforms. Errors say what is wrong with the file; nothing here panics.
|
||||
pub fn from_icc(bytes: &[u8]) -> Result<DisplayProfile, String> {
|
||||
if bytes.len() > MAX_PROFILE_BYTES {
|
||||
return Err(format!("the profile is too large ({} MB)", bytes.len() >> 20));
|
||||
}
|
||||
let p = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
|
||||
if p.color_space != DataColorSpace::Rgb {
|
||||
return Err("not an RGB profile (a display profile describes an RGB device)".into());
|
||||
}
|
||||
if !matches!(p.pcs, DataColorSpace::Xyz | DataColorSpace::Lab) {
|
||||
return Err("unsupported profile connection space".into());
|
||||
}
|
||||
let info = icc::parse(bytes).ok_or("not a valid ICC profile")?;
|
||||
let description = info
|
||||
.description
|
||||
.as_deref()
|
||||
.map(|d| d.trim_start_matches('\u{feff}').trim())
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or("Display profile")
|
||||
.to_string();
|
||||
let has_lut = p.lut_a_to_b_colorimetric.is_some()
|
||||
|| p.lut_a_to_b_perceptual.is_some()
|
||||
|| p.lut_b_to_a_colorimetric.is_some()
|
||||
|| p.lut_b_to_a_perceptual.is_some();
|
||||
let srgb = ColorProfile::new_srgb();
|
||||
|
||||
// the primaries the pipeline renders into
|
||||
let (kind, to_xyz_d50, curves) = match &info.kind {
|
||||
icc::IccKind::MatrixTrc { to_xyz_d50, trc } if !has_lut => (DisplayKind::MatrixTrc, *to_xyz_d50, Some(trc.clone())),
|
||||
icc::IccKind::MatrixTrc { to_xyz_d50, .. } => (DisplayKind::Lut, *to_xyz_d50, None),
|
||||
_ => (DisplayKind::Lut, measured_to_xyz_d50(&p)?, None),
|
||||
};
|
||||
let to_rec2020 =
|
||||
rec2020_to_xyz_d50().inverse().map(|m| m.mul(&to_xyz_d50)).and_then(white_balanced).ok_or("the profile's primaries are degenerate")?;
|
||||
if to_rec2020.0.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err("the profile's primaries are degenerate".into());
|
||||
}
|
||||
|
||||
// the proxy: the display's primaries (as the pipeline targets them), sRGB curve
|
||||
let proxy_xyz = rec2020_to_xyz_d50().mul(&to_rec2020).0;
|
||||
let mut proxy = ColorProfile::new_srgb();
|
||||
proxy.cicp = None;
|
||||
proxy.red_colorant = xyzd([proxy_xyz[0][0], proxy_xyz[1][0], proxy_xyz[2][0]]);
|
||||
proxy.green_colorant = xyzd([proxy_xyz[0][1], proxy_xyz[1][1], proxy_xyz[2][1]]);
|
||||
proxy.blue_colorant = xyzd([proxy_xyz[0][2], proxy_xyz[1][2], proxy_xyz[2][2]]);
|
||||
|
||||
let transform = |src: &ColorProfile, dst: &ColorProfile| guarded(|| src.create_transform_8bit(Layout::Rgba, dst, Layout::Rgba, opts()).ok());
|
||||
let correction = match curves {
|
||||
Some(trc) if trc.iter().all(|t| t.approx_eq(&Trc::Srgb, 2e-4)) => Correction::None,
|
||||
Some(trc) => Correction::Curves(Box::new(curve_tables(&trc))),
|
||||
None => Correction::Cms(transform(&proxy, &p).ok_or("the profile's tables can't be applied")?),
|
||||
};
|
||||
let from_srgb = transform(&srgb, &p).ok_or("can't convert sRGB to this profile")?;
|
||||
let to_srgb = transform(&proxy, &srgb).ok_or("can't convert this profile to sRGB")?;
|
||||
Ok(DisplayProfile { description, id: hash(bytes), kind, to_rec2020, correction, from_srgb, to_srgb })
|
||||
}
|
||||
|
||||
/// Does [`correct`](Self::correct) change anything (the display's curves aren't sRGB's)?
|
||||
pub fn needs_correction(&self) -> bool {
|
||||
!matches!(self.correction, Correction::None)
|
||||
}
|
||||
|
||||
/// A render in the display's primaries with the sRGB curve (the pipeline's display target) →
|
||||
/// the display's device values, in place.
|
||||
pub fn correct(&self, img: &mut Rgba8) -> Result<(), String> {
|
||||
match &self.correction {
|
||||
Correction::None => Ok(()),
|
||||
Correction::Curves(t) => {
|
||||
let map = |p: &mut [u8; 4]| *p = [t[0][p[0] as usize], t[1][p[1] as usize], t[2][p[2] as usize], p[3]];
|
||||
#[cfg(feature = "parallel")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
img.data.par_chunks_mut(1 << 14).for_each(|c| c.iter_mut().for_each(map));
|
||||
}
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
img.data.iter_mut().for_each(map);
|
||||
Ok(())
|
||||
}
|
||||
Correction::Cms(x) => apply(x, img),
|
||||
}
|
||||
}
|
||||
|
||||
/// An sRGB image → the display's device values, in place.
|
||||
pub fn from_srgb(&self, img: &mut Rgba8) -> Result<(), String> {
|
||||
apply(&self.from_srgb, img)
|
||||
}
|
||||
|
||||
/// A render in the display's primaries with the sRGB curve → sRGB (colours outside sRGB are
|
||||
/// clipped): for histograms and cached previews, which stay sRGB.
|
||||
pub fn to_srgb(&self, img: &Rgba8) -> Result<Rgba8, String> {
|
||||
let mut out = img.clone();
|
||||
apply(&self.to_srgb, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Device RGB → PCS (XYZ D50) of the pure primaries, measured through the profile's tables
|
||||
/// (LUT-only profiles have no colorant tags).
|
||||
fn measured_to_xyz_d50(p: &ColorProfile) -> Result<Mat3, String> {
|
||||
let lin = icc::linear_rec2020_profile();
|
||||
let x = guarded(|| p.create_transform_f32(Layout::Rgb, &lin, Layout::Rgb, opts()).ok()).ok_or("the profile's tables can't be applied")?;
|
||||
let src = [1.0f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
||||
let mut dst = [0f32; 9];
|
||||
guarded(|| x.transform(&src, &mut dst).ok()).ok_or("the profile's tables can't be applied")?;
|
||||
let c = |i: usize| [dst[i] as f64, dst[i + 3] as f64, dst[i + 6] as f64];
|
||||
// columns: each primary in linear Rec.2020
|
||||
let m = Mat3([c(0), c(1), c(2)]);
|
||||
if m.0.iter().flatten().any(|v| !v.is_finite()) || m.determinant().abs() < 1e-6 {
|
||||
return Err("the profile's primaries are degenerate".into());
|
||||
}
|
||||
Ok(rec2020_to_xyz_d50().mul(&m))
|
||||
}
|
||||
|
||||
/// sRGB-encoded 8-bit value → the display curve's code value, per channel.
|
||||
fn curve_tables(trc: &[Trc; 3]) -> [[u8; 256]; 3] {
|
||||
let mut t = [[0u8; 256]; 3];
|
||||
for (k, tab) in t.iter_mut().enumerate() {
|
||||
for (i, v) in tab.iter_mut().enumerate() {
|
||||
let lin = lightcraft_color::transfer::srgb_to_linear(i as f32 / 255.0);
|
||||
*v = (from_linear(&trc[k], lin) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Inverse of [`Trc::to_linear`] on 0..1 (bisection: works for tables and every parametric form).
|
||||
fn from_linear(t: &Trc, y: f32) -> f32 {
|
||||
let y = y.clamp(0.0, 1.0);
|
||||
let (mut lo, mut hi) = (0.0f32, 1.0f32);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if t.to_linear(mid) < y {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
}
|
||||
|
||||
fn apply(x: &Arc<Transform8BitExecutor>, img: &mut Rgba8) -> Result<(), String> {
|
||||
const CHUNK: usize = 1 << 14; // pixels
|
||||
let src = img.data.clone();
|
||||
let run = |s: &[[u8; 4]], d: &mut [[u8; 4]]| guarded(|| x.transform(s.as_flattened(), d.as_flattened_mut()).ok()).is_some();
|
||||
#[cfg(feature = "parallel")]
|
||||
let ok = {
|
||||
use rayon::prelude::*;
|
||||
src.par_chunks(CHUNK).zip(img.data.par_chunks_mut(CHUNK)).all(|(s, d)| run(s, d))
|
||||
};
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let ok = src.chunks(CHUNK).zip(img.data.chunks_mut(CHUNK)).all(|(s, d)| run(s, d));
|
||||
if ok { Ok(()) } else { Err("display profile transform failed".into()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::space::NamedSpace;
|
||||
use lightcraft_color::{DISPLAY_P3, SRGB};
|
||||
|
||||
fn img(px: &[[u8; 4]]) -> Rgba8 {
|
||||
Rgba8 { width: px.len(), height: 1, data: px.to_vec() }
|
||||
}
|
||||
|
||||
fn close(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
|
||||
(0..4).all(|i| (a[i] as i32 - b[i] as i32).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_display_changes_nothing() {
|
||||
let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::Srgb)).unwrap();
|
||||
assert_eq!(d.kind, DisplayKind::MatrixTrc);
|
||||
assert!(!d.needs_correction());
|
||||
for (i, row) in d.to_rec2020.0.iter().enumerate() {
|
||||
let want = SRGB.to_space(&REC2020).0[i];
|
||||
for k in 0..3 {
|
||||
assert!((row[k] - want[k]).abs() < 2e-3, "{:?}", d.to_rec2020);
|
||||
}
|
||||
}
|
||||
let px = [[0, 0, 0, 255], [255, 255, 255, 255], [200, 30, 90, 128], [17, 140, 251, 7]];
|
||||
let mut a = img(&px);
|
||||
d.correct(&mut a).unwrap();
|
||||
assert_eq!(a.data, px);
|
||||
let mut b = img(&px);
|
||||
d.from_srgb(&mut b).unwrap();
|
||||
for (x, y) in b.data.iter().zip(&px) {
|
||||
assert!(close(*x, *y, 1), "{x:?} {y:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a Display P3 monitor, sRGB red is shown with less red drive than full; P3 green (out of
|
||||
/// sRGB) is reachable in the display's own primaries.
|
||||
#[test]
|
||||
fn p3_display() {
|
||||
let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
|
||||
assert!(!d.needs_correction(), "P3 uses the sRGB curve");
|
||||
let want = DISPLAY_P3.to_space(&REC2020).0;
|
||||
for i in 0..3 {
|
||||
for k in 0..3 {
|
||||
assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 2e-3, "{:?}", d.to_rec2020);
|
||||
}
|
||||
}
|
||||
let mut red = img(&[[255, 0, 0, 255], [128, 128, 128, 255]]);
|
||||
d.from_srgb(&mut red).unwrap();
|
||||
assert!(red.data[0][0] < 245 && red.data[0][1] > 40, "{:?}", red.data[0]);
|
||||
assert!(close(red.data[1], [128, 128, 128, 255], 1), "grey stays grey: {:?}", red.data[1]);
|
||||
// the display's own pure green is outside sRGB: it clips there
|
||||
let srgb = d.to_srgb(&img(&[[0, 255, 0, 255]])).unwrap();
|
||||
assert!(srgb.data[0][0] < 10 && srgb.data[0][1] == 255, "{:?}", srgb.data[0]);
|
||||
}
|
||||
|
||||
/// A gamma 2.2 display (Adobe RGB-like): the proxy's sRGB curve goes to the display's curve.
|
||||
#[test]
|
||||
fn gamma_display_uses_its_curve() {
|
||||
let bytes = icc::write_matrix_trc(&lightcraft_color::ADOBE_RGB, &Trc::Gamma(2.2));
|
||||
let d = DisplayProfile::from_icc(&bytes).unwrap();
|
||||
assert!(d.needs_correction());
|
||||
let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [10, 128, 240, 9]]);
|
||||
d.correct(&mut a).unwrap();
|
||||
assert_eq!(a.data[0], [0, 0, 0, 255]);
|
||||
assert_eq!(a.data[1], [255, 255, 255, 255]);
|
||||
for (k, v) in [10u8, 128, 240].into_iter().enumerate() {
|
||||
let lin = lightcraft_color::transfer::srgb_to_linear(v as f32 / 255.0);
|
||||
let want = (lin.powf(1.0 / 2.2) * 255.0).round() as i32;
|
||||
assert!((a.data[2][k] as i32 - want).abs() <= 1, "{k}: {} vs {want}", a.data[2][k]);
|
||||
}
|
||||
assert_eq!(a.data[2][3], 9, "alpha kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_profiles() {
|
||||
assert!(DisplayProfile::from_icc(&[]).is_err());
|
||||
assert!(DisplayProfile::from_icc(&[0u8; 300]).is_err());
|
||||
let mut gray = ColorProfile::new_gray_with_gamma(2.2);
|
||||
gray.cicp = None;
|
||||
assert!(DisplayProfile::from_icc(&gray.encode().unwrap()).is_err());
|
||||
// truncated / corrupted real profiles never panic
|
||||
let good = icc::write_named(NamedSpace::DisplayP3);
|
||||
for n in [10, 128, 140, 200, good.len() - 1] {
|
||||
let _ = DisplayProfile::from_icc(&good[..n]);
|
||||
}
|
||||
for i in (0..good.len()).step_by(7) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0xa5;
|
||||
let _ = DisplayProfile::from_icc(&b);
|
||||
}
|
||||
}
|
||||
|
||||
/// A LUT-based display profile with no colorant or TRC tags (its A2B/B2A tables describe a
|
||||
/// Display P3 panel with a gamma 2.2 response): the primaries are measured through the tables
|
||||
/// and every preview goes through the CMS.
|
||||
fn lut_only_p3_gamma22() -> Vec<u8> {
|
||||
use moxcms::{LutMultidimensionalType, LutWarehouse, Matrix3d, ProfileClass, ToneReprCurve, Vector3d};
|
||||
let m = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).0;
|
||||
let inv = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).inverse().unwrap().0;
|
||||
let curves = |v: Vec<f32>| vec![ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v)];
|
||||
let ident = || curves(vec![1.0]);
|
||||
// mAB: A → CLUT → M → matrix → B (no CLUT: M curves linearize, the matrix goes to XYZ)
|
||||
let a2b = LutWarehouse::Multidimensional(LutMultidimensionalType {
|
||||
num_input_channels: 3,
|
||||
num_output_channels: 3,
|
||||
grid_points: [0; 16],
|
||||
clut: None,
|
||||
a_curves: vec![],
|
||||
m_curves: curves(vec![2.2]),
|
||||
matrix: Matrix3d { v: m.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
|
||||
b_curves: ident(),
|
||||
bias: Vector3d::default(),
|
||||
});
|
||||
let b2a = LutWarehouse::Multidimensional(LutMultidimensionalType {
|
||||
num_input_channels: 3,
|
||||
num_output_channels: 3,
|
||||
grid_points: [0; 16],
|
||||
clut: None,
|
||||
a_curves: vec![],
|
||||
m_curves: curves(vec![1.0 / 2.2]),
|
||||
matrix: Matrix3d { v: inv.map(|r| r.map(|v| v * 65535.0 / 32768.0)) },
|
||||
b_curves: ident(),
|
||||
bias: Vector3d::default(),
|
||||
});
|
||||
let mut p = ColorProfile::new_srgb();
|
||||
p.cicp = None;
|
||||
p.profile_class = ProfileClass::DisplayDevice;
|
||||
p.red_colorant = Xyzd::default();
|
||||
p.green_colorant = Xyzd::default();
|
||||
p.blue_colorant = Xyzd::default();
|
||||
p.red_trc = None;
|
||||
p.green_trc = None;
|
||||
p.blue_trc = None;
|
||||
p.lut_a_to_b_colorimetric = Some(a2b.clone());
|
||||
p.lut_a_to_b_perceptual = Some(a2b);
|
||||
p.lut_b_to_a_colorimetric = Some(b2a.clone());
|
||||
p.lut_b_to_a_perceptual = Some(b2a);
|
||||
p.encode().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lut_profile_goes_through_the_cms() {
|
||||
let bytes = lut_only_p3_gamma22();
|
||||
let parsed = ColorProfile::new_from_slice(&bytes).unwrap();
|
||||
assert!(!parsed.is_matrix_shaper() && parsed.lut_b_to_a_colorimetric.is_some(), "a LUT-only profile");
|
||||
let d = DisplayProfile::from_icc(&bytes).unwrap();
|
||||
assert_eq!(d.kind, DisplayKind::Lut);
|
||||
assert!(d.needs_correction());
|
||||
// the measured primaries are P3's
|
||||
let want = DISPLAY_P3.to_space(&REC2020).0;
|
||||
for i in 0..3 {
|
||||
for k in 0..3 {
|
||||
assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 0.02, "{:?} vs {want:?}", d.to_rec2020);
|
||||
}
|
||||
}
|
||||
// the correction is the gamma 2.2 curve; grey stays grey
|
||||
let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 200]]);
|
||||
d.correct(&mut a).unwrap();
|
||||
assert!(close(a.data[0], [0, 0, 0, 255], 1) && close(a.data[1], [255, 255, 255, 255], 1), "{:?}", a.data);
|
||||
let lin = lightcraft_color::transfer::srgb_to_linear(128.0 / 255.0);
|
||||
let g = (lin.powf(1.0 / 2.2) * 255.0).round() as u8;
|
||||
assert!(close(a.data[2], [g, g, g, 200], 2), "{:?} vs {g}", a.data[2]);
|
||||
// sRGB red → less than full red drive on a P3 panel
|
||||
let mut red = img(&[[255, 0, 0, 255]]);
|
||||
d.from_srgb(&mut red).unwrap();
|
||||
assert!(red.data[0][0] < 245 && red.data[0][1] > 30, "{:?}", red.data[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_identify_profiles() {
|
||||
let a = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
|
||||
let b = DisplayProfile::from_icc(&icc::write_named(NamedSpace::AdobeRgb)).unwrap();
|
||||
let a2 = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap();
|
||||
assert_ne!(a.id, b.id);
|
||||
assert_eq!(a.id, a2.id);
|
||||
}
|
||||
}
|
||||
+316
-33
@@ -1,6 +1,6 @@
|
||||
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
|
||||
//! embedded where the format allows.
|
||||
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and,
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
|
||||
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use lightcraft_raster::Rgba8;
|
||||
@@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> {
|
||||
EncodeImage { width, height, channels, samples }
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
if !(1..=4).contains(&self.channels) {
|
||||
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
|
||||
}
|
||||
@@ -79,6 +79,46 @@ pub enum ChromaSubsampling {
|
||||
|
||||
const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
|
||||
|
||||
/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF,
|
||||
/// XMP and the ICC profile in chunks, in that order.
|
||||
pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result<Vec<(u8, Vec<u8>)>> {
|
||||
let mut segs: Vec<(u8, Vec<u8>)> = Vec::new();
|
||||
let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
|
||||
let [hi, lo] = p.to_be_bytes();
|
||||
[1, hi, lo, hi, lo]
|
||||
});
|
||||
segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
|
||||
if let Some(exif) = meta.exif {
|
||||
if exif.len() > 65_527 {
|
||||
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
|
||||
}
|
||||
segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
segs.push((0xE1, xmp_segment(xmp)?));
|
||||
}
|
||||
if let Some(icc) = meta.icc {
|
||||
// ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
|
||||
let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
|
||||
if chunks.len() > 255 {
|
||||
return Err(Error::Encode("ICC profile too large for JPEG".into()));
|
||||
}
|
||||
for (i, c) in chunks.iter().enumerate() {
|
||||
segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
|
||||
}
|
||||
}
|
||||
Ok(segs)
|
||||
}
|
||||
|
||||
/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment.
|
||||
pub(crate) fn xmp_segment(xmp: &str) -> Result<Vec<u8>> {
|
||||
let seg = [XMP_NS, xmp.as_bytes()].concat();
|
||||
if seg.len() > 65_533 {
|
||||
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
|
||||
}
|
||||
Ok(seg)
|
||||
}
|
||||
|
||||
/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100.
|
||||
pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
@@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
|
||||
};
|
||||
if subsampling != ChromaSubsampling::S422 {
|
||||
// Parallel encoder (restart-interval bands on all cores).
|
||||
let mut segs: Vec<(u8, Vec<u8>)> = Vec::new();
|
||||
let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
|
||||
let [hi, lo] = p.to_be_bytes();
|
||||
[1, hi, lo, hi, lo]
|
||||
});
|
||||
segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
|
||||
if let Some(exif) = meta.exif {
|
||||
if exif.len() > 65_527 {
|
||||
return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
|
||||
}
|
||||
segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
let seg = [XMP_NS, xmp.as_bytes()].concat();
|
||||
if seg.len() > 65_533 {
|
||||
return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
|
||||
}
|
||||
segs.push((0xE1, seg));
|
||||
}
|
||||
if let Some(icc) = meta.icc {
|
||||
// ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
|
||||
let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
|
||||
if chunks.len() > 255 {
|
||||
return Err(Error::Encode("ICC profile too large for JPEG".into()));
|
||||
}
|
||||
for (i, c) in chunks.iter().enumerate() {
|
||||
segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
|
||||
}
|
||||
}
|
||||
let segs = jpeg_app_segments(meta)?;
|
||||
let ch = if img.channels == 2 { 1 } else { img.channels as usize };
|
||||
// `data` is already gray for 2-channel input (see above)
|
||||
return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs));
|
||||
@@ -279,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod
|
||||
Ok(cur.into_inner())
|
||||
}
|
||||
|
||||
/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image
|
||||
/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded.
|
||||
pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
crate::psd::encode(img, meta)
|
||||
}
|
||||
|
||||
/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container.
|
||||
pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
@@ -351,6 +369,237 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m².
|
||||
pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
|
||||
|
||||
/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1.
|
||||
pub fn pq_encode(nits: f32) -> f32 {
|
||||
const M1: f32 = 2610.0 / 16384.0;
|
||||
const M2: f32 = 2523.0 / 4096.0 * 128.0;
|
||||
const C1: f32 = 3424.0 / 4096.0;
|
||||
const C2: f32 = 2413.0 / 4096.0 * 32.0;
|
||||
const C3: f32 = 2392.0 / 4096.0 * 32.0;
|
||||
let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
|
||||
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
|
||||
}
|
||||
|
||||
/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m².
|
||||
pub fn pq_decode(e: f32) -> f32 {
|
||||
const M1: f32 = 2610.0 / 16384.0;
|
||||
const M2: f32 = 2523.0 / 4096.0 * 128.0;
|
||||
const C1: f32 = 3424.0 / 4096.0;
|
||||
const C2: f32 = 2413.0 / 4096.0 * 32.0;
|
||||
const C3: f32 = 2392.0 / 4096.0 * 32.0;
|
||||
let p = e.clamp(0.0, 1.0).powf(1.0 / M2);
|
||||
((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
|
||||
/// `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_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) {
|
||||
return Err(Error::Encode("HDR AVIF: sample buffer too short".into()));
|
||||
}
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
{
|
||||
use rav1e::prelude::*;
|
||||
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL
|
||||
let mut max_nits = 0f32;
|
||||
let mut sum_nits = 0f64;
|
||||
let planes: Vec<[u16; 3]> = rgb
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.take(n)
|
||||
.map(|c| {
|
||||
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
|
||||
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] = 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;
|
||||
[y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16)
|
||||
})
|
||||
.collect();
|
||||
let q = quality.clamp(1, 100) as f32 / 100.0;
|
||||
// ravif's quality → quantizer curve, so qualities match the SDR AVIF path
|
||||
let x = if q >= 0.82 {
|
||||
(1.0 - q) * 2.6
|
||||
} else if q > 0.25 {
|
||||
1.0 - 0.125 - q * 0.5
|
||||
} else {
|
||||
1.0 - q
|
||||
};
|
||||
let quantizer = (x * 255.0).round() as usize;
|
||||
let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10));
|
||||
ec.width = w;
|
||||
ec.height = h;
|
||||
ec.time_base = Rational::new(1, 1);
|
||||
ec.bit_depth = 10;
|
||||
ec.chroma_sampling = ChromaSampling::Cs444;
|
||||
ec.pixel_range = PixelRange::Full;
|
||||
ec.color_description = Some(ColorDescription {
|
||||
color_primaries: ColorPrimaries::BT2020,
|
||||
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 =
|
||||
(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;
|
||||
ec.speed_settings.multiref = false;
|
||||
ec.speed_settings.rdo_lookahead_frames = 1;
|
||||
ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None;
|
||||
let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}"));
|
||||
let mut ctx: Context<u16> = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?;
|
||||
let mut frame = ctx.new_frame();
|
||||
{
|
||||
let mut it = planes.iter();
|
||||
let mut fp = frame.planes.iter_mut();
|
||||
let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else {
|
||||
return Err(Error::Encode("AV1: frame has no planes".into()));
|
||||
};
|
||||
let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default()));
|
||||
for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) {
|
||||
for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) {
|
||||
let Some(p) = it.next() else { break };
|
||||
(*y, *u, *v) = (p[0], p[1], p[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.send_frame(frame).map_err(|x| e(&x))?;
|
||||
ctx.flush();
|
||||
let mut av1 = Vec::new();
|
||||
loop {
|
||||
match ctx.receive_packet() {
|
||||
Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data),
|
||||
Ok(_) => {}
|
||||
Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break,
|
||||
Err(x) => return Err(e(&x)),
|
||||
}
|
||||
}
|
||||
use avif_serialize::constants as c;
|
||||
let mut a = avif_serialize::Aviffy::new();
|
||||
a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
|
||||
.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);
|
||||
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());
|
||||
}
|
||||
Ok(a.to_vec(&av1, None, width, height, 10))
|
||||
}
|
||||
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (quality, speed, meta, transfer);
|
||||
Err(Error::Encode("AVIF encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an HDR PNG: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
|
||||
/// (`width × height × 3`), as for [`encode_avif_pq`]. Written as 16-bit RGB with the PQ curve
|
||||
/// (SDR white at [`HDR_REFERENCE_WHITE_NITS`]), described by a `cICP` chunk (PNG third edition:
|
||||
/// BT.2020 primaries, PQ, RGB, full range) and a `cLLI` content light level chunk (the same
|
||||
/// levels the HDR AVIF records). EXIF and XMP are embedded; an ICC profile is not (`cICP`
|
||||
/// describes the colour, and readers prefer it). Readers without `cICP` support show the PQ
|
||||
/// samples as if they were sRGB (flat, washed out): expected for a PQ PNG, there is no fallback.
|
||||
pub fn encode_png_pq(width: u32, height: u32, rgb: &[f32], meta: &EncodeMeta) -> 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) {
|
||||
return Err(Error::Encode("HDR PNG: sample buffer too short".into()));
|
||||
}
|
||||
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 luminance
|
||||
let mut max_nits = 0f32;
|
||||
let mut sum_nits = 0f64;
|
||||
let mut bytes = Vec::with_capacity(n * 6);
|
||||
for c in rgb.as_chunks::<3>().0.iter().take(n) {
|
||||
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
|
||||
max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
|
||||
sum_nits += (K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]) as f64;
|
||||
for v in nits {
|
||||
bytes.extend_from_slice(&((pq_encode(v) * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes());
|
||||
}
|
||||
}
|
||||
let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u32, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u32);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.exif_metadata = meta.exif.map(|b| b.to_vec().into());
|
||||
info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| {
|
||||
let ppm = (p as f64 / 0.0254).round() as u32;
|
||||
png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter }
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
let e = |e: png::EncodingError| Error::Encode(e.to_string());
|
||||
{
|
||||
let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?;
|
||||
enc.set_compression(png::Compression::Fast);
|
||||
if let Some(xmp) = meta.xmp {
|
||||
enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?;
|
||||
}
|
||||
let mut wr = enc.write_header().map_err(e)?;
|
||||
// BT.2020 primaries (9), PQ (16), RGB (0), full range (1)
|
||||
wr.write_chunk(png::chunk::ChunkType(*b"cICP"), &[9, 16, 0, 1]).map_err(e)?;
|
||||
// MaxCLL and MaxFALL in units of 0.0001 cd/m²
|
||||
let clli = [(cll.0 * 10_000).to_be_bytes(), (cll.1 * 10_000).to_be_bytes()].concat();
|
||||
wr.write_chunk(png::chunk::ChunkType(*b"cLLI"), &clli).map_err(e)?;
|
||||
wr.write_image_data(&bytes).map_err(e)?;
|
||||
wr.finish().map_err(e)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
|
||||
@@ -379,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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to
|
||||
//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays.
|
||||
//!
|
||||
//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear
|
||||
//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit
|
||||
//! codes), so quantisation in the base is handed back by the gain map.
|
||||
//! - [`apply`] rebuilds the HDR rendition from a base and its gain map.
|
||||
//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO
|
||||
//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO
|
||||
//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same
|
||||
//! map (what survives an iMessage send).
|
||||
//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom),
|
||||
//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP.
|
||||
//!
|
||||
//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) − log2(sdr + offset_sdr)` as
|
||||
//! `min + (max − min) · (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how
|
||||
//! much headroom the display has between `base_headroom` and `alternate_headroom`.
|
||||
|
||||
use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment};
|
||||
use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par};
|
||||
|
||||
/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated).
|
||||
pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0";
|
||||
|
||||
/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output.
|
||||
const DENOMINATOR: u32 = 1_000_000;
|
||||
|
||||
/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value
|
||||
/// when the map is single-channel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GainMapMeta {
|
||||
/// Three gain channels (R, G, B) instead of one luminance gain.
|
||||
pub multichannel: bool,
|
||||
/// The gain applies in the base image's colour space.
|
||||
pub use_base_colour_space: bool,
|
||||
/// log2 headroom at which the base is shown as is (0: an SDR base).
|
||||
pub base_headroom: f32,
|
||||
/// log2 headroom at which the gain map applies in full.
|
||||
pub alternate_headroom: f32,
|
||||
pub min: [f32; 3],
|
||||
pub max: [f32; 3],
|
||||
pub gamma: [f32; 3],
|
||||
pub base_offset: [f32; 3],
|
||||
pub alternate_offset: [f32; 3],
|
||||
}
|
||||
|
||||
impl Default for GainMapMeta {
|
||||
fn default() -> Self {
|
||||
GainMapMeta {
|
||||
multichannel: false,
|
||||
use_base_colour_space: true,
|
||||
base_headroom: 0.0,
|
||||
alternate_headroom: 1.0,
|
||||
min: [0.0; 3],
|
||||
max: [1.0; 3],
|
||||
gamma: [1.0; 3],
|
||||
base_offset: [1.0 / 64.0; 3],
|
||||
alternate_offset: [1.0 / 64.0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GainMapMeta {
|
||||
fn channels(&self) -> usize {
|
||||
if self.multichannel { 3 } else { 1 }
|
||||
}
|
||||
|
||||
/// Usable by [`apply`]: finite, positive gammas, max ≥ min, headrooms in order.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite());
|
||||
fin(&self.min)
|
||||
&& fin(&self.max)
|
||||
&& fin(&self.gamma)
|
||||
&& fin(&self.base_offset)
|
||||
&& fin(&self.alternate_offset)
|
||||
&& self.gamma.iter().all(|g| *g > 0.0)
|
||||
&& self.min.iter().zip(&self.max).all(|(a, b)| b >= a)
|
||||
&& self.base_headroom.is_finite()
|
||||
&& self.alternate_headroom.is_finite()
|
||||
&& self.alternate_headroom > self.base_headroom
|
||||
}
|
||||
|
||||
/// The ISO 21496-1 APP2 payload of the gain map image, URN included.
|
||||
pub fn to_iso(&self) -> Vec<u8> {
|
||||
let mut p = ISO_URN.to_vec();
|
||||
p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this
|
||||
p.extend_from_slice(&0u16.to_be_bytes()); // writer_version
|
||||
let mut flags = 0u8;
|
||||
if self.multichannel {
|
||||
flags |= FLAG_MULTICHANNEL;
|
||||
}
|
||||
if self.use_base_colour_space {
|
||||
flags |= FLAG_BASE_COLOUR_SPACE;
|
||||
}
|
||||
p.push(flags);
|
||||
let unsigned = |p: &mut Vec<u8>, v: f32| {
|
||||
let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32;
|
||||
p.extend_from_slice(&n.to_be_bytes());
|
||||
p.extend_from_slice(&DENOMINATOR.to_be_bytes());
|
||||
};
|
||||
let signed = |p: &mut Vec<u8>, v: f32| {
|
||||
let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32;
|
||||
p.extend_from_slice(&n.to_be_bytes());
|
||||
p.extend_from_slice(&DENOMINATOR.to_be_bytes());
|
||||
};
|
||||
unsigned(&mut p, self.base_headroom.max(0.0));
|
||||
unsigned(&mut p, self.alternate_headroom.max(0.0));
|
||||
for c in 0..self.channels() {
|
||||
signed(&mut p, self.min[c]);
|
||||
signed(&mut p, self.max[c]);
|
||||
unsigned(&mut p, self.gamma[c]);
|
||||
signed(&mut p, self.base_offset[c]);
|
||||
signed(&mut p, self.alternate_offset[c]);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only
|
||||
/// marker a primary image carries, unknown versions, a base that is the HDR rendition, or
|
||||
/// values no decoder could use.
|
||||
pub fn from_iso(p: &[u8]) -> Option<GainMapMeta> {
|
||||
let mut r = Reader { b: p, at: 0 };
|
||||
let min_version = r.u16()?;
|
||||
let _writer_version = r.u16()?;
|
||||
if min_version != 0 {
|
||||
return None;
|
||||
}
|
||||
let flags = r.u8()?;
|
||||
if flags & FLAG_BACKWARD != 0 {
|
||||
return None;
|
||||
}
|
||||
let multichannel = flags & FLAG_MULTICHANNEL != 0;
|
||||
let common = flags & FLAG_COMMON_DENOMINATOR != 0;
|
||||
let common_d = if common { r.u32()? } else { 0 };
|
||||
let unsigned = |r: &mut Reader| -> Option<f32> {
|
||||
let n = r.u32()?;
|
||||
let d = if common { common_d } else { r.u32()? };
|
||||
(d != 0).then(|| (n as f64 / d as f64) as f32)
|
||||
};
|
||||
let signed = |r: &mut Reader| -> Option<f32> {
|
||||
let n = r.u32()? as i32;
|
||||
let d = if common { common_d } else { r.u32()? };
|
||||
(d != 0).then(|| (n as f64 / d as f64) as f32)
|
||||
};
|
||||
let mut m = GainMapMeta {
|
||||
multichannel,
|
||||
use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0,
|
||||
base_headroom: unsigned(&mut r)?,
|
||||
alternate_headroom: unsigned(&mut r)?,
|
||||
..GainMapMeta::default()
|
||||
};
|
||||
for c in 0..m.channels() {
|
||||
m.min[c] = signed(&mut r)?;
|
||||
m.max[c] = signed(&mut r)?;
|
||||
m.gamma[c] = unsigned(&mut r)?;
|
||||
m.base_offset[c] = signed(&mut r)?;
|
||||
m.alternate_offset[c] = signed(&mut r)?;
|
||||
}
|
||||
if !multichannel {
|
||||
m.fill_from_first();
|
||||
}
|
||||
m.is_valid().then_some(m)
|
||||
}
|
||||
|
||||
fn fill_from_first(&mut self) {
|
||||
for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] {
|
||||
a[1] = a[0];
|
||||
a[2] = a[0];
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files
|
||||
/// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists.
|
||||
pub fn from_hdrgm_xmp(xmp: &str) -> Option<GainMapMeta> {
|
||||
if !xmp.contains("hdrgm:") {
|
||||
return None;
|
||||
}
|
||||
let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true")));
|
||||
if base_rendition_hdr {
|
||||
return None;
|
||||
}
|
||||
let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> {
|
||||
let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) };
|
||||
let f: Vec<f32> = v.iter().map(|s| s.trim().parse::<f32>().ok()).collect::<Option<_>>()?;
|
||||
match f.as_slice() {
|
||||
[a] => Some(([*a; 3], false)),
|
||||
[a, b, c] => Some(([*a, *b, *c], true)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let (max, m1) = get("GainMapMax", f32::NAN)?;
|
||||
if max.iter().any(|v| v.is_nan()) {
|
||||
return None; // the one required parameter
|
||||
}
|
||||
let (min, m2) = get("GainMapMin", 0.0)?;
|
||||
let (gamma, m3) = get("Gamma", 1.0)?;
|
||||
let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?;
|
||||
let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?;
|
||||
let (cap_min, _) = get("HDRCapacityMin", 0.0)?;
|
||||
let max_gain = max.iter().copied().fold(f32::MIN, f32::max);
|
||||
let (cap_max, _) = get("HDRCapacityMax", max_gain)?;
|
||||
let m = GainMapMeta {
|
||||
multichannel: m1 || m2 || m3 || m4 || m5,
|
||||
use_base_colour_space: true,
|
||||
base_headroom: cap_min[0].max(0.0),
|
||||
alternate_headroom: cap_max[0],
|
||||
min,
|
||||
max,
|
||||
gamma,
|
||||
base_offset,
|
||||
alternate_offset,
|
||||
};
|
||||
m.is_valid().then_some(m)
|
||||
}
|
||||
|
||||
/// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which
|
||||
/// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps).
|
||||
pub fn to_gain_map_xmp(&self) -> String {
|
||||
let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"];
|
||||
let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset];
|
||||
let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]);
|
||||
let mut s = String::from(XMP_OPEN);
|
||||
s.push_str(r#"<rdf:Description rdf:about="" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0""#);
|
||||
s.push_str(&format!(r#" hdrgm:HDRCapacityMin="{}""#, num(self.base_headroom)));
|
||||
s.push_str(&format!(r#" hdrgm:HDRCapacityMax="{}""#, num(self.alternate_headroom)));
|
||||
s.push_str(r#" hdrgm:BaseRenditionIsHDR="False""#);
|
||||
for (n, v) in names.iter().zip(values) {
|
||||
if uniform(v) {
|
||||
s.push_str(&format!(r#" hdrgm:{n}="{}""#, num(v[0])));
|
||||
}
|
||||
}
|
||||
s.push('>');
|
||||
// per-channel values are an rdf:Seq of three, not a comma list: readers parse each item
|
||||
for (n, v) in names.iter().zip(values) {
|
||||
if !uniform(v) {
|
||||
s.push_str(&format!("<hdrgm:{n}><rdf:Seq>"));
|
||||
for x in v {
|
||||
s.push_str(&format!("<rdf:li>{}</rdf:li>", num(*x)));
|
||||
}
|
||||
s.push_str(&format!("</rdf:Seq></hdrgm:{n}>"));
|
||||
}
|
||||
}
|
||||
s.push_str("</rdf:Description>");
|
||||
s.push_str(&self.apple_description());
|
||||
s.push_str(XMP_CLOSE);
|
||||
s
|
||||
}
|
||||
|
||||
/// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces).
|
||||
fn apple_description(&self) -> String {
|
||||
// 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels
|
||||
let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 };
|
||||
let mut s = String::from(
|
||||
r#"<rdf:Description rdf:about="" xmlns:apdi="http://ns.apple.com/pixeldatainfo/1.0/" xmlns:HDRGainMap="http://ns.apple.com/HDRGainMap/1.0/" xmlns:HDRToneMap="http://ns.apple.com/HDRToneMap/1.0/">"#,
|
||||
);
|
||||
s.push_str("<apdi:AuxiliaryImageType>urn:com:apple:photo:2020:aux:hdrgainmap</apdi:AuxiliaryImageType>");
|
||||
s.push_str(&format!("<apdi:NativeFormat>{format}</apdi:NativeFormat><apdi:StoredFormat>{format}</apdi:StoredFormat>"));
|
||||
s.push_str("<HDRGainMap:HDRGainMapVersion>131072</HDRGainMap:HDRGainMapVersion>");
|
||||
// linear, not log2: the headroom multiplier itself
|
||||
s.push_str(&format!("<HDRGainMap:HDRGainMapHeadroom>{}</HDRGainMap:HDRGainMapHeadroom>", num(self.alternate_headroom.exp2())));
|
||||
s.push_str(&format!("<HDRToneMap:AlternateHeadroom>{}</HDRToneMap:AlternateHeadroom>", num(self.alternate_headroom)));
|
||||
s.push_str("<HDRToneMap:ChannelMetadata><rdf:Seq>");
|
||||
for c in 0..self.channels() {
|
||||
s.push_str(&format!(
|
||||
r#"<rdf:li rdf:parseType="Resource"><HDRToneMap:GainMapMin>{}</HDRToneMap:GainMapMin><HDRToneMap:GainMapMax>{}</HDRToneMap:GainMapMax><HDRToneMap:Gamma>{}</HDRToneMap:Gamma><HDRToneMap:BaseOffset>{}</HDRToneMap:BaseOffset><HDRToneMap:AlternateOffset>{}</HDRToneMap:AlternateOffset></rdf:li>"#,
|
||||
num(self.min[c]),
|
||||
num(self.max[c]),
|
||||
num(self.gamma[c]),
|
||||
num(self.base_offset[c]),
|
||||
num(self.alternate_offset[c])
|
||||
));
|
||||
}
|
||||
s.push_str("</rdf:Seq></HDRToneMap:ChannelMetadata>");
|
||||
s.push_str(&format!("<HDRToneMap:BaseHeadroom>{}</HDRToneMap:BaseHeadroom>", num(self.base_headroom)));
|
||||
s.push_str("<HDRToneMap:BaseColorIsWorkingColor>True</HDRToneMap:BaseColorIsWorkingColor><HDRToneMap:Version>1</HDRToneMap:Version>");
|
||||
s.push_str("</rdf:Description>");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
const FLAG_MULTICHANNEL: u8 = 0x80;
|
||||
const FLAG_BASE_COLOUR_SPACE: u8 = 0x40;
|
||||
const FLAG_COMMON_DENOMINATOR: u8 = 0x08;
|
||||
const FLAG_BACKWARD: u8 = 0x04;
|
||||
|
||||
const XMP_OPEN: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">";
|
||||
const XMP_CLOSE: &str = "</rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";
|
||||
|
||||
/// A short decimal (up to 6 significant digits, no exponent for ordinary values).
|
||||
fn num(v: f32) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let s = format!("{:.6}", v);
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// The values of `hdrgm:<name>`: an attribute (`name="v"`) or an element holding an `rdf:Seq`.
|
||||
fn xmp_values(xmp: &str, name: &str) -> Option<Vec<String>> {
|
||||
let attr = format!("hdrgm:{name}=");
|
||||
if let Some(i) = xmp.find(&attr) {
|
||||
let rest = xmp.get(i + attr.len()..)?;
|
||||
let q = rest.chars().next()?;
|
||||
if q == '"' || q == '\'' {
|
||||
let body = rest.get(1..)?;
|
||||
let end = body.find(q)?;
|
||||
return Some(vec![body.get(..end)?.to_string()]);
|
||||
}
|
||||
}
|
||||
let open = format!("<hdrgm:{name}>");
|
||||
let close = format!("</hdrgm:{name}>");
|
||||
let i = xmp.find(&open)?;
|
||||
let body = xmp.get(i + open.len()..)?;
|
||||
let body = body.get(..body.find(&close)?)?;
|
||||
let mut out = Vec::new();
|
||||
let mut rest = body;
|
||||
while let Some(a) = rest.find("<rdf:li>") {
|
||||
let after = rest.get(a + 8..)?;
|
||||
let b = after.find("</rdf:li>")?;
|
||||
out.push(after.get(..b)?.to_string());
|
||||
rest = after.get(b..)?;
|
||||
if out.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
// a plain element value
|
||||
out.push(body.trim().to_string());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn take<const N: usize>(&mut self) -> Option<[u8; N]> {
|
||||
let s = self.b.get(self.at..self.at.checked_add(N)?)?;
|
||||
self.at += N;
|
||||
s.try_into().ok()
|
||||
}
|
||||
fn u8(&mut self) -> Option<u8> {
|
||||
self.take::<1>().map(|a| a[0])
|
||||
}
|
||||
fn u16(&mut self) -> Option<u16> {
|
||||
self.take().map(u16::from_be_bytes)
|
||||
}
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
self.take().map(u32::from_be_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GainImage {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub channels: usize,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl GainImage {
|
||||
/// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5).
|
||||
fn sample(&self, x: f32, y: f32, c: usize) -> f32 {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let fx = (x - 0.5).clamp(0.0, (w - 1) as f32);
|
||||
let fy = (y - 0.5).clamp(0.0, (h - 1) as f32);
|
||||
let (x0, y0) = (fx as usize, fy as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
|
||||
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
|
||||
let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32;
|
||||
let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx;
|
||||
let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx;
|
||||
a + (b - a) * ty
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`compute`] builds the map.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GainMapOptions {
|
||||
/// Downscale factor of the map (1, 2 or 4).
|
||||
pub scale: usize,
|
||||
/// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single
|
||||
/// channel, so this is off by default.
|
||||
pub multichannel: bool,
|
||||
pub gamma: f32,
|
||||
/// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet).
|
||||
pub offset: f32,
|
||||
}
|
||||
|
||||
impl Default for GainMapOptions {
|
||||
fn default() -> Self {
|
||||
GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the gain map that turns `sdr` into `hdr` (both `width × height` linear RGB in the same
|
||||
/// primaries; `luma` = that space's luminance weights).
|
||||
pub fn compute(
|
||||
sdr: &[[f32; 3]],
|
||||
hdr: &[[f32; 3]],
|
||||
width: usize,
|
||||
height: usize,
|
||||
luma: [f32; 3],
|
||||
o: &GainMapOptions,
|
||||
) -> Result<(GainImage, GainMapMeta)> {
|
||||
let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
|
||||
if n == 0 || sdr.len() < n || hdr.len() < n {
|
||||
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
|
||||
}
|
||||
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
|
||||
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
|
||||
}
|
||||
let s = o.scale;
|
||||
let (gw, gh) = (width.div_ceil(s), height.div_ceil(s));
|
||||
let ch = if o.multichannel { 3 } else { 1 };
|
||||
let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2];
|
||||
let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2();
|
||||
// block means of the log gains
|
||||
let mut gains = vec![0f32; gw * gh * ch];
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
for gy in 0..gh {
|
||||
for gx in 0..gw {
|
||||
let mut acc = [0f32; 3];
|
||||
let mut count = 0f32;
|
||||
for yy in gy * s..((gy + 1) * s).min(height) {
|
||||
for xx in gx * s..((gx + 1) * s).min(width) {
|
||||
let i = yy * width + xx;
|
||||
let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue };
|
||||
if o.multichannel {
|
||||
for c in 0..3 {
|
||||
acc[c] += ratio(a[c], b[c]);
|
||||
}
|
||||
} else {
|
||||
acc[0] += ratio(y(a), y(b));
|
||||
}
|
||||
count += 1.0;
|
||||
}
|
||||
}
|
||||
for c in 0..ch {
|
||||
let g = if count > 0.0 { acc[c] / count } else { 0.0 };
|
||||
let g = if g.is_finite() { g } else { 0.0 };
|
||||
gains[(gy * gw + gx) * ch + c] = g;
|
||||
lo[c] = lo[c].min(g);
|
||||
hi[c] = hi[c].max(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut m = GainMapMeta {
|
||||
multichannel: o.multichannel,
|
||||
gamma: [o.gamma; 3],
|
||||
base_offset: [o.offset; 3],
|
||||
alternate_offset: [o.offset; 3],
|
||||
..GainMapMeta::default()
|
||||
};
|
||||
for c in 0..ch {
|
||||
// the range always includes 0 (no change) so an SDR-only area encodes exactly
|
||||
m.min[c] = lo[c].min(0.0);
|
||||
m.max[c] = hi[c].max(m.min[c] + 1e-4);
|
||||
}
|
||||
if !o.multichannel {
|
||||
m.fill_from_first();
|
||||
}
|
||||
m.base_headroom = 0.0;
|
||||
m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3);
|
||||
let data = gains
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let c = i % ch;
|
||||
let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0);
|
||||
(norm.powf(o.gamma) * 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
Ok((GainImage { width: gw, height: gh, channels: ch, data }, m))
|
||||
}
|
||||
|
||||
/// Rebuild the HDR rendition in place: `base` (`width × height` linear RGB) is replaced by the
|
||||
/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom
|
||||
/// `H` uses `(H − base_headroom) / (alternate_headroom − base_headroom)`, clamped).
|
||||
pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> {
|
||||
let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?;
|
||||
if base.len() < n
|
||||
|| gain.width == 0
|
||||
|| gain.height == 0
|
||||
|| !matches!(gain.channels, 1 | 3)
|
||||
|| gain.data.len() < gain.width * gain.height * gain.channels
|
||||
{
|
||||
return Err(Error::Encode("gain map: base and gain map sizes don't match".into()));
|
||||
}
|
||||
if !m.is_valid() {
|
||||
return Err(Error::Encode("gain map: unusable metadata".into()));
|
||||
}
|
||||
let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32);
|
||||
let inv_gamma = m.gamma.map(|g| 1.0 / g);
|
||||
for (i, p) in base.iter_mut().enumerate().take(n) {
|
||||
let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5);
|
||||
let (gx, gy) = (x * sx, y * sy);
|
||||
let mut g = [0f32; 3];
|
||||
for (c, gc) in g.iter_mut().enumerate() {
|
||||
let src = if gain.channels == 3 { c } else { 0 };
|
||||
let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]);
|
||||
*gc = m.min[c] + (m.max[c] - m.min[c]) * norm;
|
||||
}
|
||||
for c in 0..3 {
|
||||
let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c];
|
||||
p[c] = v.max(0.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for
|
||||
/// Adobe/Google readers) and the Container directory naming the gain map and its length.
|
||||
fn primary_description(gain_len: usize) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
r#"<rdf:Description rdf:about="" xmlns:Container="http://ns.google.com/photos/1.0/container/" xmlns:Item="http://ns.google.com/photos/1.0/container/item/" xmlns:hdrgm="http://ns.adobe.com/hdr-gain-map/1.0/" hdrgm:Version="1.0">"#,
|
||||
r#"<Container:Directory><rdf:Seq>"#,
|
||||
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="Primary" Item:Mime="image/jpeg"/></rdf:li>"#,
|
||||
r#"<rdf:li rdf:parseType="Resource"><Container:Item Item:Semantic="GainMap" Item:Mime="image/jpeg" Item:Length="{}"/></rdf:li>"#,
|
||||
r#"</rdf:Seq></Container:Directory></rdf:Description>"#
|
||||
),
|
||||
gain_len
|
||||
)
|
||||
}
|
||||
|
||||
/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is
|
||||
/// none, or when it has no `rdf:RDF` to add to).
|
||||
fn merge_xmp(xmp: Option<&str>, description: &str) -> String {
|
||||
if let Some(x) = xmp
|
||||
&& let Some(i) = x.rfind("</rdf:RDF>")
|
||||
&& let (Some(a), Some(b)) = (x.get(..i), x.get(i..))
|
||||
{
|
||||
return format!("{a}{description}{b}");
|
||||
}
|
||||
format!("{XMP_OPEN}{description}{XMP_CLOSE}")
|
||||
}
|
||||
|
||||
/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags).
|
||||
const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4;
|
||||
|
||||
/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists.
|
||||
fn mpf_placeholder() -> Vec<u8> {
|
||||
let mut p = b"MPF\0MM".to_vec();
|
||||
p.extend_from_slice(&42u16.to_be_bytes());
|
||||
p.extend_from_slice(&8u32.to_be_bytes()); // first IFD
|
||||
p.extend_from_slice(&3u16.to_be_bytes());
|
||||
let tag = |p: &mut Vec<u8>, tag: u16, ty: u16, count: u32, value: [u8; 4]| {
|
||||
p.extend_from_slice(&tag.to_be_bytes());
|
||||
p.extend_from_slice(&ty.to_be_bytes());
|
||||
p.extend_from_slice(&count.to_be_bytes());
|
||||
p.extend_from_slice(&value);
|
||||
};
|
||||
tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion
|
||||
tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages
|
||||
tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry
|
||||
p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
|
||||
// entry 0: baseline primary (0x030000) flagged as the representative image (bit 29)
|
||||
for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] {
|
||||
p.extend_from_slice(&attr.to_be_bytes());
|
||||
p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and
|
||||
/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added.
|
||||
pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
let Samples::U8(data) = base.samples else {
|
||||
return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into()));
|
||||
};
|
||||
let (w, h) = (base.width as usize, base.height as usize);
|
||||
if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize {
|
||||
return Err(Error::Encode("gain map JPEG needs an RGB base image".into()));
|
||||
}
|
||||
if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize {
|
||||
return Err(Error::Encode("JPEG dimensions are limited to 65535".into()));
|
||||
}
|
||||
if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels {
|
||||
return Err(Error::Encode("gain map image is malformed".into()));
|
||||
}
|
||||
if !m.is_valid() || m.multichannel != (gain.channels == 3) {
|
||||
return Err(Error::Encode("gain map metadata doesn't match the map".into()));
|
||||
}
|
||||
// jpeg_par writes 4:4:4 or 4:2:0
|
||||
let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub };
|
||||
|
||||
let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())];
|
||||
let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs);
|
||||
|
||||
let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len()));
|
||||
let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?;
|
||||
let mut iso_marker = ISO_URN.to_vec();
|
||||
iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map"
|
||||
segs.push((0xE2, iso_marker));
|
||||
segs.push((0xE2, mpf_placeholder()));
|
||||
let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs);
|
||||
|
||||
// patch the MPF index: sizes, and the gain map's offset from the MPF endian field
|
||||
let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?;
|
||||
let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?;
|
||||
let entries = endian + MPF_ENTRIES_FROM_ENDIAN;
|
||||
let (plen, glen) = (primary.len(), gain_jpeg.len());
|
||||
let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)];
|
||||
for (at, v) in fields {
|
||||
let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?;
|
||||
let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?;
|
||||
slot.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
primary.extend_from_slice(&gain_jpeg);
|
||||
Ok(primary)
|
||||
}
|
||||
|
||||
/// The gain map of a gain map JPEG.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct JpegGainMap {
|
||||
pub gain: GainImage,
|
||||
pub meta: GainMapMeta,
|
||||
}
|
||||
|
||||
/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The
|
||||
/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`].
|
||||
pub fn read_jpeg(bytes: &[u8]) -> Option<JpegGainMap> {
|
||||
let m = jpeg::parse_markers(bytes)?;
|
||||
let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"));
|
||||
if !marked {
|
||||
return None;
|
||||
}
|
||||
for e in jpeg::mpf_images(&m) {
|
||||
let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue };
|
||||
let Some(gm) = jpeg::parse_markers(data) else { continue };
|
||||
let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp));
|
||||
let Some(meta) = meta else { continue };
|
||||
let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue };
|
||||
if ch == 3 && !meta.multichannel {
|
||||
// a single-channel map stored as RGB: use its first channel
|
||||
let g: Vec<u8> = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect();
|
||||
return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta });
|
||||
}
|
||||
let mut meta = meta;
|
||||
if ch == 1 && meta.multichannel {
|
||||
meta.multichannel = false;
|
||||
meta.fill_from_first();
|
||||
}
|
||||
return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode).
|
||||
pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool {
|
||||
crate::sniff(bytes) == Some(Format::Jpeg)
|
||||
&& jpeg::parse_markers(bytes).is_some_and(|m| {
|
||||
(m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")))
|
||||
&& jpeg::mpf_images(&m)
|
||||
.iter()
|
||||
.any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta3() -> GainMapMeta {
|
||||
GainMapMeta {
|
||||
multichannel: true,
|
||||
use_base_colour_space: true,
|
||||
base_headroom: 0.0,
|
||||
alternate_headroom: 2.5,
|
||||
min: [-0.25, -0.5, 0.0],
|
||||
max: [2.5, 2.25, 2.0],
|
||||
gamma: [1.0, 1.25, 0.75],
|
||||
base_offset: [0.015625; 3],
|
||||
alternate_offset: [0.015625, 0.03125, 0.015625],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_metadata_round_trips() {
|
||||
for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] {
|
||||
let p = m.to_iso();
|
||||
assert!(p.starts_with(ISO_URN));
|
||||
let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap();
|
||||
for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(back.multichannel, m.multichannel);
|
||||
assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_common_denominator_and_rejections() {
|
||||
// common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0
|
||||
let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR];
|
||||
for v in [4u32, 0, 8, 0, 8, 4, 0, 0] {
|
||||
p.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let m = GainMapMeta::from_iso(&p).unwrap();
|
||||
assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0));
|
||||
// versions only (a primary image's marker), unknown version, truncated, backward
|
||||
assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none());
|
||||
assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none());
|
||||
assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none());
|
||||
let mut back = p.clone();
|
||||
back[4] |= FLAG_BACKWARD;
|
||||
assert!(GainMapMeta::from_iso(&back).is_none());
|
||||
// a zero denominator is rejected, not divided by
|
||||
let mut z = p.clone();
|
||||
z[5..9].copy_from_slice(&0u32.to_be_bytes());
|
||||
assert!(GainMapMeta::from_iso(&z).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdrgm_xmp_round_trips_including_per_channel_sequences() {
|
||||
for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] {
|
||||
let x = m.to_gain_map_xmp();
|
||||
assert!(x.contains("apdi:AuxiliaryImageType"));
|
||||
if m.multichannel {
|
||||
assert!(x.contains("<hdrgm:GainMapMax><rdf:Seq><rdf:li>2.5</rdf:li>"), "{x}");
|
||||
}
|
||||
let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap();
|
||||
assert_eq!(back.multichannel, m.multichannel);
|
||||
for c in 0..3 {
|
||||
assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5);
|
||||
}
|
||||
assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5);
|
||||
}
|
||||
assert!(GainMapMeta::from_hdrgm_xmp("<x hdrgm:Version=\"1.0\"/>").is_none(), "no GainMapMax");
|
||||
assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none());
|
||||
}
|
||||
|
||||
fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
|
||||
// a ramp: SDR rolls off at 1, HDR keeps going to 6
|
||||
let hdr: Vec<[f32; 3]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let t = (i % w) as f32 / (w - 1) as f32;
|
||||
let v = 0.01 + 6.0 * t * t;
|
||||
[v, v * 0.8, v * 0.6]
|
||||
})
|
||||
.collect();
|
||||
// a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single
|
||||
// luminance gain can rebuild it exactly
|
||||
let sdr = hdr
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2];
|
||||
let k = 1.0 / (1.0 + y * 0.5);
|
||||
p.map(|v| v * k)
|
||||
})
|
||||
.collect();
|
||||
(sdr, hdr)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_then_apply_recovers_the_hdr_rendition() {
|
||||
let (w, h) = (64, 8);
|
||||
let (sdr, hdr) = renditions(w, h);
|
||||
for multichannel in [false, true] {
|
||||
let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() };
|
||||
let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap();
|
||||
assert!(m.is_valid() && m.alternate_headroom > 1.0);
|
||||
let mut out = sdr.clone();
|
||||
apply(&mut out, w, h, &g, &m, 1.0).unwrap();
|
||||
for (a, b) in out.iter().zip(&hdr) {
|
||||
for c in 0..3 {
|
||||
// 8-bit codes over the gain range: within ~2 % in linear light
|
||||
assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
// weight 0 is the base
|
||||
let mut base = sdr.clone();
|
||||
apply(&mut base, w, h, &g, &m, 0.0).unwrap();
|
||||
for (a, b) in base.iter().zip(&sdr) {
|
||||
assert!((a[0] - b[0]).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_round_trip_and_container_structure() {
|
||||
let (w, h) = (96, 64);
|
||||
let (sdr, hdr) = renditions(w, h);
|
||||
let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
|
||||
let base: Vec<u8> =
|
||||
sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect();
|
||||
let user_xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() };
|
||||
let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap();
|
||||
assert!(is_gain_map_jpeg(&file));
|
||||
// the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP
|
||||
let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (w as u32, h as u32));
|
||||
let x = d.xmp.unwrap();
|
||||
assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\""));
|
||||
// the MPF index points at the gain map, typed as one
|
||||
let pm = jpeg::parse_markers(&file).unwrap();
|
||||
let e = jpeg::mpf_images(&pm);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP);
|
||||
assert_eq!(e[0].offset + e[0].len, file.len());
|
||||
// and the gain map reads back
|
||||
let r = read_jpeg(&file).unwrap();
|
||||
assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1));
|
||||
assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5);
|
||||
// the gain map is never a thumbnail
|
||||
if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) {
|
||||
assert_ne!(t.source, crate::ThumbnailSource::MpfPreview);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_never_panics() {
|
||||
assert!(read_jpeg(b"").is_none());
|
||||
assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none());
|
||||
assert!(!is_gain_map_jpeg(&[0xFF, 0xD8]));
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40];
|
||||
b.extend_from_slice(ISO_URN);
|
||||
b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]);
|
||||
assert!(read_jpeg(&b).is_none());
|
||||
let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] };
|
||||
assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err());
|
||||
assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err());
|
||||
}
|
||||
}
|
||||
+54
-28
@@ -1,16 +1,18 @@
|
||||
//! HEIF / HEIC (the iPhone and Mac photo format), read-only.
|
||||
//!
|
||||
//! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC
|
||||
//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC
|
||||
//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call
|
||||
//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still
|
||||
//! detected and opening one is an [`Error::Unsupported`] error, never a panic.
|
||||
//! still-picture decoder, plus a libheif-matching colour conversion), enabled by this crate's
|
||||
//! `heif` feature — off by default because HEVC is patent-encumbered and whether a build carries
|
||||
//! an HEVC decoder is the distributor's call (the same policy as the sibling PhotoCraft). Without
|
||||
//! the feature, HEIF files are still detected and opening one is an [`Error::Unsupported`] error
|
||||
//! saying so, never a panic.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
|
||||
//! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only
|
||||
//! mirrors what the container says, and applying it as well would turn the photo twice.
|
||||
//! The samples come back in the container's own primaries with its ICC profile (if any); like
|
||||
//! every decoder here, the colour interpretation happens in [`crate::convert::finish`].
|
||||
//! The samples come back as 16-bit RGB in the container's own primaries, with its ICC profile
|
||||
//! (iPhones: Display P3) or, without one, its `nclx` / VUI colour description; like every decoder
|
||||
//! here, the colour interpretation happens in [`crate::convert::finish`].
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
use crate::convert::{Buf, Meta, Model, Raw, finish};
|
||||
@@ -22,26 +24,32 @@ const F: Format = Format::Heif;
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
|
||||
|
||||
/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
|
||||
/// only; the wrapper names the exact construct in its own errors).
|
||||
#[cfg(feature = "heif")]
|
||||
const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)";
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result<Decoded> {
|
||||
Err(Error::Unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn header(_bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
Err(Error::Unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
fn err(e: lightcraft_heif::Error) -> Error {
|
||||
match e {
|
||||
// This crate's Unsupported takes a fixed reason; the dynamic wording lives in the
|
||||
// lightcraft-heif error itself.
|
||||
lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED),
|
||||
lightcraft_heif::Error::Unsupported(why) => Error::Unsupported(F, why),
|
||||
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
|
||||
}
|
||||
}
|
||||
|
||||
/// The displayed size (container transforms applied, so orientation 1), from the container
|
||||
/// alone: no picture is decoded.
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let info = lightcraft_heif::probe(bytes).map_err(err)?;
|
||||
Ok((info.width, info.height, 1))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
@@ -50,16 +58,22 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return Err(Error::TooLarge(info.width as u64, info.height as u64));
|
||||
}
|
||||
let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true };
|
||||
let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?;
|
||||
// The decoder re-reads the container; trust its output's shape, not the probe's.
|
||||
let buf = if decoded.sixteen_bit {
|
||||
let mut v = Vec::with_capacity(decoded.data.len() / 2);
|
||||
for pair in decoded.data.as_chunks::<2>().0 {
|
||||
v.push(u16::from_ne_bytes(*pair));
|
||||
// A small decode (a thumbnail or a cataloguing probe) uses the file's own thumbnail image when
|
||||
// it is at least as large as asked: iPhones store one of 320 × 240, and decoding it is a
|
||||
// hundredth of the work of the 12 MP photo.
|
||||
let thumb = match opts.max_size {
|
||||
Some((mw, mh)) if mw > 0 && mh > 0 && mw.max(mh) < info.width.max(info.height) => {
|
||||
lightcraft_heif::decode_thumbnail(bytes, mw.max(mh), &options).ok().flatten().filter(|t| {
|
||||
// Only a thumbnail of the same picture: same orientation and aspect within 1 %.
|
||||
let aspect = |w: u32, h: u32| w as f64 / h.max(1) as f64;
|
||||
(aspect(t.width, t.height) / aspect(info.width, info.height) - 1.0).abs() < 0.01
|
||||
})
|
||||
}
|
||||
Buf::U16(v)
|
||||
} else {
|
||||
Buf::U8(decoded.data)
|
||||
_ => None,
|
||||
};
|
||||
let mut decoded = match thumb {
|
||||
Some(t) => t,
|
||||
None => lightcraft_heif::decode(bytes, &options).map_err(err)?,
|
||||
};
|
||||
let raw = Raw {
|
||||
width: decoded.width as usize,
|
||||
@@ -67,11 +81,20 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
model: Model::Rgb,
|
||||
alpha: decoded.has_alpha,
|
||||
premultiplied: false,
|
||||
buf,
|
||||
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
|
||||
buf: Buf::U16(std::mem::take(&mut decoded.data)),
|
||||
bit_depth: decoded.bit_depth,
|
||||
};
|
||||
let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() };
|
||||
finish(F, raw, meta, (decoded.width, decoded.height), opts)
|
||||
let c = decoded.colour;
|
||||
let meta = Meta {
|
||||
icc: decoded.icc,
|
||||
exif: decoded.exif,
|
||||
xmp: decoded.xmp,
|
||||
orientation: Some(1),
|
||||
// Used when there is no (usable) ICC profile.
|
||||
hint: crate::space::SourceSpace::from_cicp(c.primaries, c.transfer),
|
||||
..Default::default()
|
||||
};
|
||||
finish(F, raw, meta, (info.width, info.height), opts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -96,6 +119,8 @@ mod tests {
|
||||
fn without_the_feature_heif_is_a_clear_unsupported_error() {
|
||||
let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}");
|
||||
let h = crate::read_header(HEIC_HEADER);
|
||||
assert!(matches!(&h, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{h:?}");
|
||||
}
|
||||
|
||||
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
|
||||
@@ -113,6 +138,7 @@ mod tests {
|
||||
fn a_heic_rs_panic_is_a_malformed_error() {
|
||||
let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}");
|
||||
assert!(crate::read_header(&HEIC_RS_BOX_PANIC).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
@@ -120,6 +146,6 @@ mod tests {
|
||||
fn an_image_sequence_is_unsupported() {
|
||||
// An ftyp with no meta box: a video track, not a still.
|
||||
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}");
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("sequence")), "{r:?}");
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec<u8> {
|
||||
write_matrix_trc(&n.rgb_space(), &n.trc())
|
||||
}
|
||||
|
||||
fn linear_rec2020_profile() -> ColorProfile {
|
||||
pub(crate) fn linear_rec2020_profile() -> ColorProfile {
|
||||
let mut p = ColorProfile::new_bt2020();
|
||||
p.cicp = None;
|
||||
let lin = ToneReprCurve::Parametric(vec![1.0]);
|
||||
|
||||
@@ -25,6 +25,17 @@ pub(crate) struct Markers {
|
||||
pub adobe_transform: Option<u8>,
|
||||
/// Offset of the MPF TIFF header within the file and its bytes.
|
||||
pub mpf: Option<(usize, Vec<u8>)>,
|
||||
/// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image
|
||||
/// that has a gain map, the full metadata on the gain map image itself.
|
||||
pub iso_gainmap: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Markers {
|
||||
/// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a
|
||||
/// picture: never a preview, never the image to show.
|
||||
pub fn is_gain_map(&self) -> bool {
|
||||
self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
@@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
icc_chunks.push((seg[12], &seg[14..]));
|
||||
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
|
||||
m.mpf = Some((start + 4, seg[4..].to_vec()));
|
||||
} else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
|
||||
&& m.iso_gainmap.is_none()
|
||||
{
|
||||
m.iso_gainmap = Some(p.to_vec());
|
||||
}
|
||||
}
|
||||
0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => {
|
||||
@@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
|
||||
if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) {
|
||||
candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail));
|
||||
}
|
||||
for (o, l) in mpf_images(&m) {
|
||||
if let Some(s) = bytes.get(o..o.saturating_add(l)) {
|
||||
for e in mpf_images(&m) {
|
||||
if e.kind == MPF_GAIN_MAP {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) {
|
||||
candidates.push((s, ThumbnailSource::MpfPreview));
|
||||
}
|
||||
}
|
||||
@@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
|
||||
let mut best: Option<(u32, &[u8], ThumbnailSource)> = None;
|
||||
for (data, src) in candidates {
|
||||
let Some(pm) = parse_markers(data) else { continue };
|
||||
// a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview
|
||||
if pm.is_gain_map() {
|
||||
continue;
|
||||
}
|
||||
let long = pm.width.max(pm.height);
|
||||
// Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped.
|
||||
let aspect_ok = m.width > 0
|
||||
@@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) ->
|
||||
Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height })
|
||||
}
|
||||
|
||||
/// (file offset, length) of MPF-listed images other than the primary.
|
||||
fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits).
|
||||
pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000;
|
||||
|
||||
/// An MPF-listed image other than the primary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MpfEntry {
|
||||
/// File offset and length of the image.
|
||||
pub offset: usize,
|
||||
pub len: usize,
|
||||
/// MP type code (attribute low 24 bits).
|
||||
pub kind: u32,
|
||||
}
|
||||
|
||||
/// The MPF-listed images other than the primary.
|
||||
pub(crate) fn mpf_images(m: &Markers) -> Vec<MpfEntry> {
|
||||
let Some((base, data)) = &m.mpf else { return vec![] };
|
||||
let Some(t) = exif::Tiff::new(data) else { return vec![] };
|
||||
let Some(ifd) = t.first_ifd() else { return vec![] };
|
||||
@@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]];
|
||||
if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
|
||||
};
|
||||
let (size, off) = (rd(4) as usize, rd(8) as usize);
|
||||
let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize);
|
||||
if size > 0 && off > 0 {
|
||||
out.push((base + off, size));
|
||||
out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale
|
||||
/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours.
|
||||
pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec<u8>)> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
}
|
||||
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
|
||||
if !matches!(m.components, 1 | 3) || m.precision != 8 {
|
||||
return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into()));
|
||||
}
|
||||
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
|
||||
let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? };
|
||||
let ch = raw.model.channels();
|
||||
match raw.buf {
|
||||
Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)),
|
||||
_ => Err(Error::Malformed(F, "unexpected sample format".into())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
|
||||
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
|
||||
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
|
||||
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
|
||||
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
@@ -24,11 +26,13 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let gray = header.metadata.grayscale();
|
||||
let icc = img.original_icc().map(|v| v.to_vec());
|
||||
let mut meta = Meta { orientation: Some(1), ..Default::default() };
|
||||
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
|
||||
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
|
||||
if icc.is_none() {
|
||||
img.request_color_encoding(EnumColourEncoding {
|
||||
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
|
||||
white_point: WhitePoint::D65,
|
||||
primaries: Primaries::Bt2100,
|
||||
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
|
||||
tf: TransferFunction::Linear,
|
||||
rendering_intent: RenderingIntent::Relative,
|
||||
});
|
||||
@@ -55,11 +59,19 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let keep = n_color + has_alpha as usize;
|
||||
let src = fb.buf();
|
||||
let mut v = Vec::with_capacity(fw * fh * keep);
|
||||
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
|
||||
for px in src.chunks_exact(ch) {
|
||||
let start = v.len();
|
||||
v.extend_from_slice(&px[..keep.min(ch)]);
|
||||
if keep > ch {
|
||||
v.push(1.0);
|
||||
}
|
||||
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
|
||||
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
|
||||
for (o, m) in rgb.iter_mut().zip(&to_2020) {
|
||||
*o = m[0] * r + m[1] * g + m[2] * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = Raw {
|
||||
width: fw,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
|
||||
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
|
||||
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
|
||||
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
|
||||
|
||||
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
|
||||
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
|
||||
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
|
||||
/// exports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JxlEffort {
|
||||
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
|
||||
Fast,
|
||||
#[default]
|
||||
Normal,
|
||||
}
|
||||
|
||||
impl JxlEffort {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"fast" => Self::Fast,
|
||||
"normal" => Self::Normal,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum JxlColour {
|
||||
#[default]
|
||||
Srgb,
|
||||
/// P3 primaries, D65, sRGB curve.
|
||||
DisplayP3,
|
||||
/// Rec. 2020 primaries with the BT.709 curve.
|
||||
Rec2020,
|
||||
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
|
||||
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
|
||||
/// samples are already PQ-encoded.
|
||||
Rec2020Pq,
|
||||
}
|
||||
|
||||
/// JPEG XL encoder settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct JxlOptions {
|
||||
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
|
||||
pub quality: u8,
|
||||
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
|
||||
pub lossless: bool,
|
||||
pub effort: JxlEffort,
|
||||
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
|
||||
/// preview that refines to the exact image).
|
||||
pub progressive: bool,
|
||||
pub colour: JxlColour,
|
||||
}
|
||||
|
||||
impl Default for JxlOptions {
|
||||
fn default() -> Self {
|
||||
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
|
||||
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
|
||||
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
|
||||
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
|
||||
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let pixels = Pixels::from(img, o.colour)?;
|
||||
// jixel asserts internally; a panic there is an export error, not a crash
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
|
||||
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
|
||||
}
|
||||
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (o, meta);
|
||||
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved RGB or RGBA samples for jixel.
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
enum Pixels {
|
||||
U8 { data: Vec<u8>, alpha: bool },
|
||||
U16 { data: Vec<u16>, alpha: bool },
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
impl Pixels {
|
||||
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
|
||||
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
let ch = img.channels as usize;
|
||||
// keep alpha only when it says something
|
||||
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
|
||||
let px = s.chunks_exact(ch).take(n);
|
||||
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
|
||||
let out_ch = if alpha { 4 } else { 3 };
|
||||
let mut v = Vec::with_capacity(n * out_ch);
|
||||
for c in px {
|
||||
match c {
|
||||
[g] => v.extend_from_slice(&[*g, *g, *g]),
|
||||
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
|
||||
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
|
||||
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
|
||||
[] => {}
|
||||
}
|
||||
}
|
||||
(v, alpha)
|
||||
}
|
||||
Ok(match img.samples {
|
||||
Samples::U8(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
|
||||
Pixels::U8 { data, alpha }
|
||||
}
|
||||
Samples::U16(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
|
||||
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
|
||||
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
|
||||
let s: Vec<u16> = s
|
||||
.chunks_exact(ch)
|
||||
.take(n)
|
||||
.flat_map(|c| match c {
|
||||
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
|
||||
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
|
||||
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
|
||||
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
|
||||
[] => [0; 4],
|
||||
})
|
||||
.collect();
|
||||
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
|
||||
let colour = match o.colour {
|
||||
JxlColour::Srgb => ColorEncoding::srgb(),
|
||||
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
|
||||
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
|
||||
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
|
||||
};
|
||||
let mut c = EncodeConfig::default()
|
||||
.with_quality(f32::from(o.quality.clamp(1, 100)))
|
||||
.with_color_encoding(colour)
|
||||
.with_lossless(o.lossless)
|
||||
.with_progressive(o.progressive)
|
||||
.with_speed(match o.effort {
|
||||
JxlEffort::Fast => Speed::Fastest,
|
||||
JxlEffort::Normal => Speed::Fast,
|
||||
});
|
||||
if o.colour == JxlColour::Rec2020Pq {
|
||||
// PQ code values are absolute: 1.0 = 10 000 cd/m²
|
||||
c = c.with_intensity_target(10_000.0);
|
||||
}
|
||||
if o.lossless
|
||||
&& let Some(icc) = meta.icc
|
||||
{
|
||||
c = c.with_icc_profile(icc.to_vec());
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
c = c.with_exif(exif.to_vec());
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
c = c.with_xmp(xmp.as_bytes().to_vec());
|
||||
}
|
||||
let r = match px {
|
||||
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
|
||||
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
|
||||
};
|
||||
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
|
||||
}
|
||||
+33
-12
@@ -9,7 +9,8 @@
|
||||
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
|
||||
//! decoding pixels (import probes).
|
||||
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
|
||||
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
@@ -17,16 +18,20 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
pub mod display;
|
||||
pub mod encode;
|
||||
pub mod exif;
|
||||
pub mod gainmap;
|
||||
mod heif;
|
||||
pub mod icc;
|
||||
mod jpeg;
|
||||
pub mod jpeg_par;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
pub mod proof;
|
||||
mod psd;
|
||||
mod sniff;
|
||||
pub mod space;
|
||||
@@ -34,8 +39,10 @@ mod tiff_codec;
|
||||
mod webp;
|
||||
|
||||
pub use encode::{
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, 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};
|
||||
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
|
||||
|
||||
@@ -171,9 +178,9 @@ pub struct Header {
|
||||
}
|
||||
|
||||
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
|
||||
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
|
||||
/// decode.
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read
|
||||
/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to
|
||||
/// a small decode.
|
||||
///
|
||||
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
|
||||
/// unsupported layout, compression or frame type) and files whose image data is cut short
|
||||
@@ -196,6 +203,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
|
||||
Format::Tiff => tiff_codec::header(bytes)?,
|
||||
Format::WebP => webp::header(bytes)?,
|
||||
Format::Psd => psd::header(bytes)?,
|
||||
Format::Heif => heif::header(bytes)?,
|
||||
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
|
||||
_ => {
|
||||
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
|
||||
@@ -217,6 +225,8 @@ pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: Na
|
||||
#[doc(hidden)]
|
||||
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let opts = *opts;
|
||||
// The reason a format can't be decoded lives in one place, `Format::not_decodable`.
|
||||
let unsupported = || Err(Error::Unsupported(format, format.not_decodable().unwrap_or("not decodable in this build")));
|
||||
match format {
|
||||
Format::Jpeg => jpeg::decode(bytes, &opts),
|
||||
Format::Png => png_codec::decode(bytes, &opts),
|
||||
@@ -227,20 +237,31 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
||||
#[cfg(feature = "jxl")]
|
||||
Format::Jxl => jxl::decode(bytes, &opts),
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
|
||||
Format::Jxl => unsupported(),
|
||||
Format::Heif => heif::decode(bytes, &opts),
|
||||
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
|
||||
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
if let Some(c) = &info.coding_independent_code_points {
|
||||
let named = match c.color_primaries {
|
||||
1 => Some(NamedSpace::Srgb),
|
||||
9 => Some(NamedSpace::Rec2020),
|
||||
12 => Some(NamedSpace::DisplayP3),
|
||||
_ => None,
|
||||
};
|
||||
let trc = match c.transfer_function {
|
||||
13 => Some(Trc::Srgb),
|
||||
1 | 6 | 14 | 15 => Some(Trc::Rec709),
|
||||
8 => Some(Trc::Linear),
|
||||
4 => Some(Trc::Gamma(2.2)),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(n), Some(t)) = (named, trc) {
|
||||
let mut s = SourceSpace::named(n, SpaceOrigin::Container);
|
||||
s.trc = Some([t.clone(), t.clone(), t]);
|
||||
return Some(s);
|
||||
}
|
||||
if let Some(s) =
|
||||
info.coding_independent_code_points.as_ref().and_then(|c| SourceSpace::from_cicp(c.color_primaries.into(), c.transfer_function.into()))
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
if info.srgb.is_some() {
|
||||
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix
|
||||
//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020)
|
||||
//! through the device and back, so the loupe shows what the print can hold.
|
||||
//!
|
||||
//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative
|
||||
//! colorimetric: colours outside the device gamut come back mapped into it.
|
||||
//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white
|
||||
//! (the profile's media white point) replaces the screen's white, and the ink's black is the
|
||||
//! darkest the device tables give (no black-point compensation). The white is applied as a
|
||||
//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the
|
||||
//! near-neutral whites of papers).
|
||||
//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour.
|
||||
//!
|
||||
//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling);
|
||||
//! the transforms are moxcms's. Own design otherwise.
|
||||
|
||||
use lightcraft_color::{D50, D65, REC2020, bradford};
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions};
|
||||
|
||||
use crate::display::MAX_PROFILE_BYTES;
|
||||
|
||||
/// Rendering intent of the forward (image → device) conversion.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum ProofIntent {
|
||||
#[default]
|
||||
Perceptual,
|
||||
Relative,
|
||||
}
|
||||
|
||||
impl ProofIntent {
|
||||
pub fn parse(s: &str) -> Option<ProofIntent> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"perceptual" => Some(ProofIntent::Perceptual),
|
||||
"relative" | "relativecolorimetric" => Some(ProofIntent::Relative),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ProofIntent::Perceptual => "perceptual",
|
||||
ProofIntent::Relative => "relative",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index
|
||||
/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance
|
||||
/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProofTable {
|
||||
pub n: usize,
|
||||
pub data: Vec<[f32; 4]>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Grid size of [`ProofTable`]s.
|
||||
pub const PROOF_GRID: usize = 33;
|
||||
|
||||
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
|
||||
}
|
||||
|
||||
/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here
|
||||
/// panics on a hostile profile.
|
||||
pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result<ProofTable, String> {
|
||||
if bytes.len() > MAX_PROFILE_BYTES {
|
||||
return Err("the profile is too large".into());
|
||||
}
|
||||
let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?;
|
||||
let (layout, ch) = match dev.color_space {
|
||||
DataColorSpace::Rgb => (Layout::Rgb, 3),
|
||||
DataColorSpace::Cmyk => (Layout::Rgba, 4),
|
||||
DataColorSpace::Gray => (Layout::Gray, 1),
|
||||
_ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()),
|
||||
};
|
||||
let lin = crate::icc::linear_rec2020_profile();
|
||||
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
|
||||
let fwd_i = match intent {
|
||||
ProofIntent::Perceptual => RenderingIntent::Perceptual,
|
||||
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
|
||||
};
|
||||
let rel = RenderingIntent::RelativeColorimetric;
|
||||
let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?;
|
||||
let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?;
|
||||
let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?;
|
||||
|
||||
let n = PROOF_GRID;
|
||||
let grid: Vec<f32> = (0..n * n * n)
|
||||
.flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)))
|
||||
.collect();
|
||||
let round_trip = |there: &dyn moxcms::TransformExecutor<f32>| -> Option<Vec<f32>> {
|
||||
let mut device = vec![0f32; n * n * n * ch];
|
||||
there.transform(&grid, &mut device).ok()?;
|
||||
let mut out = vec![0f32; n * n * n * 3];
|
||||
back.transform(&device, &mut out).ok()?;
|
||||
Some(out)
|
||||
};
|
||||
let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?;
|
||||
let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?;
|
||||
let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] };
|
||||
let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0));
|
||||
let data = (0..n * n * n)
|
||||
.map(|i| {
|
||||
let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0);
|
||||
let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max);
|
||||
[c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist]
|
||||
})
|
||||
.collect();
|
||||
let description = crate::icc::parse(bytes).and_then(|i| i.description);
|
||||
Ok(ProofTable { n, data, description })
|
||||
}
|
||||
|
||||
/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point.
|
||||
fn paper_white(p: &ColorProfile) -> [f32; 3] {
|
||||
let Some(w) = p.media_white_point else { return [1.0; 3] };
|
||||
let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz());
|
||||
let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] };
|
||||
// the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it
|
||||
let d50 = to_xyz.apply([1.0; 3]);
|
||||
let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]];
|
||||
let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]);
|
||||
if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::space::NamedSpace;
|
||||
|
||||
fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] {
|
||||
t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_proof_flags_wide_colours_and_keeps_grey() {
|
||||
let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap();
|
||||
let top = t.n - 1;
|
||||
// Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged
|
||||
assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0]));
|
||||
let g = at(&t, [16, 16, 16]);
|
||||
assert!(g[3] < 0.01, "{g:?}");
|
||||
let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0);
|
||||
assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}");
|
||||
let w = at(&t, [top; 3]);
|
||||
assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_white_dims_and_tints_white() {
|
||||
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
|
||||
// a warm, slightly dull paper
|
||||
p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 });
|
||||
let bytes = p.encode().unwrap();
|
||||
let top = PROOF_GRID - 1;
|
||||
let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap();
|
||||
let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap();
|
||||
let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3]));
|
||||
assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}");
|
||||
assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_profiles_are_errors() {
|
||||
assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err());
|
||||
assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err());
|
||||
let good = crate::icc::write_named(NamedSpace::DisplayP3);
|
||||
for i in (0..good.len()).step_by(11) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
let _ = proof_table(&b, ProofIntent::Perceptual, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description):
|
||||
//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data;
|
||||
//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative.
|
||||
//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document
|
||||
//! with no layers, so an image editor opens it as one background layer and saves back to it.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::encode::{EncodeImage, EncodeMeta, Samples};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
|
||||
const F: Format = Format::Psd;
|
||||
@@ -211,6 +214,129 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
|
||||
}
|
||||
|
||||
/// The largest side a PSD (version 1) may have; larger documents need PSB.
|
||||
pub const MAX_SIDE: u32 = 30_000;
|
||||
|
||||
/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF
|
||||
/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits
|
||||
/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB;
|
||||
/// alpha is dropped (the render is opaque). Float samples are refused.
|
||||
pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w > MAX_SIDE || h > MAX_SIDE {
|
||||
return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF")));
|
||||
}
|
||||
let ch = usize::from(img.channels);
|
||||
let color = if ch <= 2 { 1 } else { 3 };
|
||||
let (depth, bpc) = match img.samples {
|
||||
Samples::U8(_) => (8u16, 1usize),
|
||||
Samples::U16(_) => (16, 2),
|
||||
Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())),
|
||||
};
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"8BPS");
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&(color as u16).to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&depth.to_be_bytes());
|
||||
b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||||
let res = resource_section(meta);
|
||||
b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes());
|
||||
b.extend_from_slice(&res);
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none
|
||||
// planar samples, one plane per colour channel (big-endian)
|
||||
let plane = |c: usize| -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(wu * hu * bpc);
|
||||
match img.samples {
|
||||
Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)),
|
||||
Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())),
|
||||
Samples::F32(_) => {}
|
||||
}
|
||||
p
|
||||
};
|
||||
if bpc == 1 {
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
let mut counts = Vec::with_capacity(color * hu * 2);
|
||||
let mut data = Vec::new();
|
||||
for c in 0..color {
|
||||
for row in plane(c).chunks_exact(wu) {
|
||||
let start = data.len();
|
||||
packbits(row, &mut data);
|
||||
let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?;
|
||||
counts.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(&counts);
|
||||
b.extend_from_slice(&data);
|
||||
} else {
|
||||
b.extend_from_slice(&0u16.to_be_bytes());
|
||||
for c in 0..color {
|
||||
b.extend_from_slice(&plane(c));
|
||||
}
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// The image resources section for `meta`.
|
||||
fn resource_section(meta: &EncodeMeta) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut add = |id: u16, data: &[u8]| {
|
||||
out.extend_from_slice(b"8BIM");
|
||||
out.extend_from_slice(&id.to_be_bytes());
|
||||
out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
};
|
||||
if let Some(ppi) = meta.ppi.filter(|p| *p > 0) {
|
||||
// ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice
|
||||
let fixed = (u32::from(ppi) << 16).to_be_bytes();
|
||||
let one = 1u16.to_be_bytes();
|
||||
add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat());
|
||||
}
|
||||
if let Some(icc) = meta.icc {
|
||||
add(1039, icc);
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
add(1058, exif);
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
add(1060, xmp.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the
|
||||
/// rest as literals of at most 128 bytes).
|
||||
fn packbits(src: &[u8], out: &mut Vec<u8>) {
|
||||
let mut rest = src;
|
||||
while let Some(&b) = rest.first() {
|
||||
// a run of 2 to 128 equal bytes
|
||||
let run = rest.iter().take(128).take_while(|&&x| x == b).count();
|
||||
if run >= 2 {
|
||||
// 1 - run in 2..=128: -1..=-127, as a signed byte
|
||||
out.push((1 - run as i16) as i8 as u8);
|
||||
out.push(b);
|
||||
rest = rest.get(run..).unwrap_or_default();
|
||||
continue;
|
||||
}
|
||||
// a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row
|
||||
let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count();
|
||||
let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired };
|
||||
let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..]));
|
||||
out.push(lit.len().saturating_sub(1) as u8);
|
||||
out.extend_from_slice(lit);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
|
||||
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
|
||||
/// depth, colour mode or compression, or merged image data that runs past the end of the file
|
||||
@@ -434,4 +560,55 @@ mod tests {
|
||||
let q = d.image.get(0, 0);
|
||||
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
#[test]
|
||||
fn packbits_round_trips() {
|
||||
let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::<Vec<_>>()];
|
||||
for row in rows {
|
||||
let mut enc = Vec::new();
|
||||
super::packbits(row, &mut enc);
|
||||
let mut out = vec![0u8; row.len()];
|
||||
unpackbits(&enc, &mut out).unwrap();
|
||||
assert_eq!(out, row);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_psd_round_trips_8_and_16_bit() {
|
||||
let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb);
|
||||
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x:xmpmeta/>"), ppi: Some(300), ..Default::default() };
|
||||
// 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient
|
||||
let px: Vec<u8> = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect();
|
||||
let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap();
|
||||
assert_eq!(super::header(&b).unwrap(), (5, 3, 1));
|
||||
let d = decode(&b, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (5, 3));
|
||||
assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept");
|
||||
assert_eq!(d.xmp.as_deref(), Some("<x:xmpmeta/>"));
|
||||
// 16-bit RGB, raw planes: the samples come back exactly
|
||||
let px16: Vec<u16> = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect();
|
||||
let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap();
|
||||
assert_eq!(&b[22..24], &16u16.to_be_bytes());
|
||||
let mut r = Rd { b: &b, p: 26 };
|
||||
let cm = r.u32().unwrap() as usize;
|
||||
r.take(cm).unwrap();
|
||||
let res = r.u32().unwrap() as usize;
|
||||
r.take(res).unwrap();
|
||||
assert_eq!(r.u32().unwrap(), 0, "no layers");
|
||||
assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw");
|
||||
let red: Vec<u16> = (0..8).map(|_| r.u16().unwrap()).collect();
|
||||
assert_eq!(red, px16.iter().step_by(3).copied().collect::<Vec<_>>());
|
||||
assert!(decode(&b, &DecodeOptions::default()).is_ok());
|
||||
// gray 16-bit
|
||||
let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap();
|
||||
assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode");
|
||||
assert!(decode(&g, &DecodeOptions::default()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_psd_refuses_what_it_cannot_hold() {
|
||||
let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err();
|
||||
assert!(e.to_string().contains("float"), "{e}");
|
||||
let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err();
|
||||
assert!(e.to_string().contains("TIFF"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,20 @@ impl Format {
|
||||
|
||||
/// Whether [`crate::decode`] can decode this format in this build.
|
||||
pub fn can_decode(self) -> bool {
|
||||
self.not_decodable().is_none()
|
||||
}
|
||||
|
||||
/// Why [`crate::decode`] can't decode this format in this build (`None`: it can). The
|
||||
/// [`crate::Error::Unsupported`] a decode returns carries the same reason.
|
||||
pub fn not_decodable(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Format::Jxl => cfg!(feature = "jxl"),
|
||||
Format::Heif => cfg!(feature = "heif"),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
|
||||
_ => true,
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Some("built without the `jxl` feature"),
|
||||
#[cfg(not(feature = "heif"))]
|
||||
Format::Heif => Some(crate::heif::NOT_IN_BUILD),
|
||||
Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"),
|
||||
Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,27 @@ impl SourceSpace {
|
||||
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
|
||||
}
|
||||
|
||||
/// The space named by ITU-T H.273 code points (PNG `cICP`, HEIF `nclx`, HEVC VUI), when
|
||||
/// both the primaries and the transfer are ones this crate models.
|
||||
pub(crate) fn from_cicp(primaries: u16, transfer: u16) -> Option<SourceSpace> {
|
||||
let named = match primaries {
|
||||
1 => NamedSpace::Srgb,
|
||||
9 => NamedSpace::Rec2020,
|
||||
12 => NamedSpace::DisplayP3,
|
||||
_ => return None,
|
||||
};
|
||||
let trc = match transfer {
|
||||
13 => Trc::Srgb,
|
||||
1 | 6 | 14 | 15 => Trc::Rec709,
|
||||
8 => Trc::Linear,
|
||||
4 => Trc::Gamma(2.2),
|
||||
_ => return None,
|
||||
};
|
||||
let mut s = SourceSpace::named(named, SpaceOrigin::Container);
|
||||
s.trc = Some([trc.clone(), trc.clone(), trc]);
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
|
||||
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
|
||||
if let Some(n) = self.named {
|
||||
|
||||
@@ -66,6 +66,15 @@ fn bench_24mp_jpeg() {
|
||||
let rgba = d.to_srgb8();
|
||||
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
|
||||
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
|
||||
#[cfg(feature = "jxl")]
|
||||
for (label, o) in [
|
||||
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
|
||||
("encode JPEG XL q90 effort normal", JxlOptions::default()),
|
||||
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
|
||||
] {
|
||||
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
|
||||
println!(" size {:.1} MB", b.len() as f64 / 1e6);
|
||||
}
|
||||
}
|
||||
|
||||
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
|
||||
|
||||
@@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) {
|
||||
let _ = read_header(bytes);
|
||||
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
let _ = lightcraft_codecs::gainmap::read_jpeg(bytes);
|
||||
let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes);
|
||||
}
|
||||
|
||||
fn gain_map_jpeg(meta: &EncodeMeta) -> Vec<u8> {
|
||||
use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg};
|
||||
let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect();
|
||||
let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect();
|
||||
let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
|
||||
let base: Vec<u8> = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect();
|
||||
encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -91,6 +102,7 @@ fn seeds() -> &'static Vec<Vec<u8>> {
|
||||
encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(),
|
||||
encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(),
|
||||
encode_webp_lossless(&e, &meta).unwrap(),
|
||||
gain_map_jpeg(&meta),
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -106,7 +118,7 @@ proptest! {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
|
||||
fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
|
||||
let mut b = seeds()[which].clone();
|
||||
for (i, v) in flips {
|
||||
let i = i.index(b.len());
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from
|
||||
//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a
|
||||
//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input.
|
||||
#![cfg(feature = "heif")]
|
||||
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_heif::testdata::{Prop, Spec, build};
|
||||
|
||||
fn grey(x: u32, y: u32) -> [u16; 3] {
|
||||
[((x * 7 + y * 13) % 220 + 20) as u16, 128, 128]
|
||||
}
|
||||
|
||||
/// EXIF whose Orientation (6) only mirrors the container's `irot`: it must not turn the photo again.
|
||||
fn exif_orientation_6() -> Vec<u8> {
|
||||
let mut t = b"MM\0\x2a\0\0\0\x08\0\x01".to_vec();
|
||||
t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0]);
|
||||
t.extend_from_slice(&[0, 0, 0, 0]);
|
||||
t
|
||||
}
|
||||
|
||||
/// The thumbnail is flat mid grey so a test can tell it from the photo.
|
||||
fn iphone_like() -> Vec<u8> {
|
||||
build(&Spec {
|
||||
props: vec![Prop::Icc(icc::write_named(NamedSpace::DisplayP3)), Prop::Irot(3)],
|
||||
exif: Some(exif_orientation_6()),
|
||||
thumbnail: Some(&|_, _| [126, 128, 128]),
|
||||
ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap"],
|
||||
..Spec::new(96, 64, &grey)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_iphone_like_heic_decodes_upright_in_display_p3() {
|
||||
let bytes = iphone_like();
|
||||
assert_eq!(sniff(&bytes), Some(Format::Heif));
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
// irot 3 = 90° clockwise, applied by the decoder; the EXIF Orientation is not applied again.
|
||||
assert_eq!((d.width, d.height, d.source_width, d.source_height, d.orientation), (64, 96, 64, 96, 1));
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
|
||||
assert_eq!((d.bit_depth, d.has_alpha, d.float), (8, false, false));
|
||||
assert!(d.exif.is_some() && d.icc.is_some());
|
||||
// No nclx and no VUI: limited range, BT.601; neutral chroma stays neutral.
|
||||
let srgb = d.to_srgb8();
|
||||
let px = srgb.data[0];
|
||||
assert!(px[0].abs_diff(px[1]) <= 1 && px[1].abs_diff(px[2]) <= 1, "{px:?}");
|
||||
|
||||
let h = read_header(&bytes).unwrap();
|
||||
assert_eq!((h.format, h.width, h.height, h.orientation), (Format::Heif, 64, 96, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_decode_uses_the_embedded_thumbnail() {
|
||||
let bytes = iphone_like();
|
||||
// The 48 × 32 thumbnail, turned like the photo, covers a 32-pixel request.
|
||||
let d = decode(&bytes, DecodeOptions::fit(32, 32)).unwrap();
|
||||
assert!(d.width.max(d.height) <= 32 && d.height > d.width, "{}x{}", d.width, d.height);
|
||||
assert_eq!((d.source_width, d.source_height), (64, 96));
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
let flat = d.to_srgb8();
|
||||
assert!(flat.data.iter().all(|p| *p == flat.data[0]), "the flat thumbnail, not the photo");
|
||||
// A thumbnail too small for the request: the photo is decoded and resized.
|
||||
let d = decode(&bytes, DecodeOptions::fit(60, 60)).unwrap();
|
||||
assert_eq!((d.width.max(d.height), d.source_width), (60, 64));
|
||||
let photo = d.to_srgb8();
|
||||
assert!(photo.data.iter().any(|p| *p != photo.data[0]));
|
||||
let t = decode_thumbnail(&bytes, 32).unwrap();
|
||||
assert_eq!((t.source_width, t.source_height), (64, 96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nclx_colour_and_ten_bit() {
|
||||
// Display P3 primaries, sRGB transfer, BT.709 matrix, full range: the nclx alone names the space.
|
||||
let bytes = build(&Spec { bit_depth: 10, props: vec![Prop::Nclx(12, 13, 1, true)], ..Spec::new(64, 32, &|x, _| [(x * 16) as u16, 512, 512]) });
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
assert_eq!(d.space.origin, SpaceOrigin::Container);
|
||||
assert_eq!(d.bit_depth, 10);
|
||||
// Full-range 10-bit neutral steps of 16 code values: linear values rise monotonically.
|
||||
let row: Vec<f32> = (0..64).map(|x| d.image.row(0)[x][1]).collect();
|
||||
assert!(row.windows(2).all(|w| w[1] > w[0]), "{row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_heic_is_an_error_never_a_panic() {
|
||||
let bytes = iphone_like();
|
||||
let full = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
for cut in (12..bytes.len()).step_by(11) {
|
||||
let b = &bytes[..cut];
|
||||
// Cut inside the photo: an error. Cut in a trailing item (gain map, thumbnail): the photo.
|
||||
if let (Ok(_), Ok(d)) = (read_header(b), decode(b, DecodeOptions::default())) {
|
||||
assert_eq!(d.image.data, full.image.data, "cut at {cut}");
|
||||
}
|
||||
let _ = decode_thumbnail(b, 16);
|
||||
}
|
||||
let r = decode(&bytes, DecodeOptions { max_pixels: 64 * 96 - 1, ..Default::default() });
|
||||
assert!(matches!(r, Err(Error::TooLarge(64, 96))), "{r:?}");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -635,3 +653,183 @@ fn print_resolution_is_written() {
|
||||
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() {
|
||||
// linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let rgb: Vec<f32> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let v = 0.01 * 1.11f32.powi((i % w) as i32);
|
||||
[v, v * 0.5, v * 0.25]
|
||||
})
|
||||
.collect();
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"/>"#;
|
||||
let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap();
|
||||
let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap();
|
||||
let cicp = r.info().coding_independent_code_points.expect("cICP");
|
||||
assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ");
|
||||
let mut buf = vec![0; r.output_buffer_size().unwrap()];
|
||||
let f = r.next_frame(&mut buf).unwrap();
|
||||
assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h));
|
||||
// every sample decodes back to its luminance
|
||||
for x in [0usize, 20, 63] {
|
||||
for c in 0..3 {
|
||||
let o = (x * 3 + c) * 2;
|
||||
let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0;
|
||||
let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS;
|
||||
assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e));
|
||||
}
|
||||
}
|
||||
// cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m²
|
||||
let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI");
|
||||
let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
|
||||
let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS;
|
||||
assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}");
|
||||
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept");
|
||||
assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept");
|
||||
assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
|
||||
assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err());
|
||||
assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err());
|
||||
assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err());
|
||||
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
|
||||
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
|
||||
}
|
||||
|
||||
/// Largest per-channel difference between two linear images.
|
||||
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossless_exact_with_metadata() {
|
||||
let img = gradient(97, 61);
|
||||
let icc = write_named(NamedSpace::Srgb);
|
||||
let exif = minimal_exif(1);
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
|
||||
let o = JxlOptions { lossless: true, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
|
||||
assert_eq!(sniff(&b), Some(Format::Jxl));
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (97, 61));
|
||||
assert!(!d.has_alpha, "opaque alpha is dropped");
|
||||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||||
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
|
||||
assert_eq!(d.xmp.as_deref(), Some(xmp));
|
||||
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
|
||||
|
||||
// 16-bit lossless keeps every code value
|
||||
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
|
||||
let want = decode(&png, DecodeOptions::default()).unwrap();
|
||||
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_quality_and_effort() {
|
||||
let img = smooth(160, 120);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
|
||||
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
|
||||
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
|
||||
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
|
||||
for (q, b) in [(95, &hi), (40, &lo)] {
|
||||
let d = decode(b, DecodeOptions::default()).unwrap();
|
||||
let p = psnr8(&opaque, &d.to_srgb8());
|
||||
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
|
||||
}
|
||||
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
|
||||
for progressive in [false, true] {
|
||||
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
|
||||
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
|
||||
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
|
||||
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
|
||||
// profile, decode to the same colours in the working space
|
||||
let img = smooth(96, 64);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
|
||||
let icc = write_named(named);
|
||||
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
|
||||
let e = EncodeImage::rgba8(&opaque);
|
||||
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
|
||||
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
|
||||
let a = decode(&lossy, DecodeOptions::default()).unwrap();
|
||||
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
|
||||
let b = decode(&exact, DecodeOptions::default()).unwrap();
|
||||
let d = max_diff(&a.to_working(), &b.to_working());
|
||||
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_alpha_and_gray() {
|
||||
let mut img = gradient(32, 24);
|
||||
img.data[5][3] = 0;
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert!(d.has_alpha);
|
||||
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
|
||||
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
|
||||
.unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_hdr_pq() {
|
||||
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
|
||||
let (w, h) = (64usize, 32usize);
|
||||
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
|
||||
for lossless in [false, true] {
|
||||
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
|
||||
// the file's own samples: PQ code values for 203 and 812 cd/m²
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
|
||||
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
|
||||
let r = img.render_frame(0).unwrap();
|
||||
let fb = r.image_all_channels();
|
||||
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
|
||||
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
|
||||
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
|
||||
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
|
||||
}
|
||||
}
|
||||
// float samples are HDR only
|
||||
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
|
||||
assert!(matches!(sdr, Err(Error::Encode(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_bad_input_is_an_error() {
|
||||
let px = [0u8; 12];
|
||||
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
// a 1×1 image and quality 1 still encode
|
||||
let one = [10u8, 20, 30];
|
||||
let b =
|
||||
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
|
||||
}
|
||||
@@ -149,7 +149,8 @@ impl RgbSpace {
|
||||
}
|
||||
}
|
||||
|
||||
const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
|
||||
/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in.
|
||||
pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
|
||||
|
||||
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
|
||||
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! blend with the plain demosaic that the Amount slider controls.
|
||||
//! - [`cpu`] and [`runtime`]: a single-thread pure-Rust CPU runner and its installation self-test.
|
||||
//! - [`net`] and [`onnx`]: checked plain network data and bounded ONNX decoding, shared by CPU and GPU.
|
||||
//! - [`remote`]: running tiles on another computer's graphics card over TCP (a [`run::TileRunner`] and its server).
|
||||
//!
|
||||
//! Everything here treats model files, manifests and cache files as hostile input: sizes are capped, numbers must
|
||||
//! be finite, and a malformed file is an error, never a panic.
|
||||
@@ -26,6 +27,7 @@ pub mod onnx;
|
||||
mod onnx_proto;
|
||||
|
||||
pub mod reference;
|
||||
pub mod remote;
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod runtime;
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Remote denoise: run a model's tiles on another computer's graphics card over TCP. Meant for a LAN or a VPN such as
|
||||
//! Tailscale or WireGuard: the connection itself is not encrypted.
|
||||
//!
|
||||
//! [`Remote`] is a [`TileRunner`] that sends each tile to a server; [`serve`] runs one (`lightcraft-cli
|
||||
//! denoise-serve`). Both ends must run the same model, checked by its identity ([`ModelId`]) when they greet.
|
||||
//!
|
||||
//! Protocol (version 1). Each message is one JSON line (`\n`-terminated, ≤ 4 KiB), optionally followed by a payload of
|
||||
//! `bytes` bytes: the tile's samples as little-endian half floats, byte-shuffled (every sample's low byte, then every
|
||||
//! high byte) and deflated, which roughly halves what crosses a slow link.
|
||||
//!
|
||||
//! ```text
|
||||
//! → {"v":1,"token":"…","op":"hello"}
|
||||
//! ← {"ok":true,"v":1,"device":"GPU: …","model":{"id":"…","version":"…","sha256":"…"}}
|
||||
//! → {"v":1,"token":"…","op":"run","model":"<sha256>","tile":T,"bytes":N} + N bytes (4 planes of T × T)
|
||||
//! ← {"ok":true,"tile":T,"bytes":N} + N bytes (3 planes of 2T × 2T)
|
||||
//! ← {"ok":false,"error":"…"} (any failure)
|
||||
//! ```
|
||||
//!
|
||||
//! A connection serves any number of requests. The server refuses a wrong token, another model than its own, tiles of
|
||||
//! another size, oversized headers and payloads (also ones that inflate past their tile), and caps concurrent
|
||||
//! connections; the client gives up on a server that keeps failing, so the computer's own runner does the rest.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::product::{from_f16, to_f16};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
pub const PROTOCOL: u64 = 1;
|
||||
pub const DEFAULT_PORT: u16 = 7990;
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// Largest tile side in cells (a 1024-cell tile is a 2048 × 2048 pixel answer).
|
||||
pub const MAX_TILE: usize = 1024;
|
||||
/// Shortest token a server accepts.
|
||||
pub const MIN_TOKEN: usize = 16;
|
||||
|
||||
/// Which model a server runs: both ends must agree before tiles cross.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ModelId {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
/// SHA-256 of the model file (hex).
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
fn bad(m: impl Into<String>) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, m.into())
|
||||
}
|
||||
|
||||
fn read_line(r: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||
let mut buf = Vec::new();
|
||||
let n = r.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf)?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if buf.last() != Some(&b'\n') {
|
||||
return Err(bad("message header too long or truncated"));
|
||||
}
|
||||
String::from_utf8(buf).map(Some).map_err(|_| bad("header is not UTF-8"))
|
||||
}
|
||||
|
||||
/// Samples → half floats → byte-shuffled → deflated.
|
||||
pub fn pack(data: &[f32]) -> Vec<u8> {
|
||||
let n = data.len();
|
||||
let mut b = vec![0u8; n * 2];
|
||||
let (lo, hi) = b.split_at_mut(n);
|
||||
for ((v, l), h) in data.iter().zip(lo.iter_mut()).zip(hi.iter_mut()) {
|
||||
[*l, *h] = to_f16(*v).to_le_bytes();
|
||||
}
|
||||
miniz_oxide::deflate::compress_to_vec(&b, 1)
|
||||
}
|
||||
|
||||
/// The inverse of [`pack`] for exactly `n` samples; refuses anything that inflates to more.
|
||||
pub fn unpack(bytes: &[u8], n: usize) -> std::io::Result<Vec<f32>> {
|
||||
let b = miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, n * 2).map_err(|_| bad("payload doesn't inflate to the tile size"))?;
|
||||
if b.len() != n * 2 {
|
||||
return Err(bad("payload size doesn't match the tile"));
|
||||
}
|
||||
let (lo, hi) = b.split_at(n);
|
||||
Ok(lo.iter().zip(hi).map(|(l, h)| from_f16(u16::from_le_bytes([*l, *h]))).collect())
|
||||
}
|
||||
|
||||
fn write_payload(w: &mut impl Write, mut header: Value, data: &[f32]) -> std::io::Result<()> {
|
||||
let packed = pack(data);
|
||||
header["bytes"] = json!(packed.len());
|
||||
writeln!(w, "{header}")?;
|
||||
w.write_all(&packed)
|
||||
}
|
||||
|
||||
/// Read a `bytes`-byte payload holding `n` samples (`bytes` is capped by the tile, so a hostile header can't make us
|
||||
/// allocate more than an uncompressed tile plus slack).
|
||||
fn read_payload(r: &mut impl Read, bytes: usize, n: usize) -> std::io::Result<Vec<f32>> {
|
||||
if bytes > n * 2 + 4096 {
|
||||
return Err(bad("payload larger than its tile"));
|
||||
}
|
||||
let mut b = vec![0u8; bytes];
|
||||
r.read_exact(&mut b)?;
|
||||
unpack(&b, n)
|
||||
}
|
||||
|
||||
fn field(v: &Value, k: &str) -> Option<usize> {
|
||||
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
|
||||
}
|
||||
|
||||
/// Constant-time comparison (the token must not leak through timing).
|
||||
fn same(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
let mut d = a.len() ^ b.len();
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
d |= (a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)) as usize;
|
||||
}
|
||||
d == 0
|
||||
}
|
||||
|
||||
/// What a server runs a tile with: four planes of `tile × tile` cells in, three planes of `2·tile × 2·tile` out.
|
||||
pub type RunTile = Arc<dyn Fn(&[f32]) -> Result<Vec<f32>, Error> + Send + Sync>;
|
||||
|
||||
/// A server's settings.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerOptions {
|
||||
/// Shared secret every request must carry (at least [`MIN_TOKEN`] characters).
|
||||
pub token: String,
|
||||
pub max_connections: usize,
|
||||
/// Idle and transfer limit per connection.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ServerOptions {
|
||||
fn default() -> Self {
|
||||
ServerOptions { token: String::new(), max_connections: 8, timeout: Duration::from_secs(120) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve `model` (tiles of `tile` cells, run by `run` on `device`) until the listener fails. `log` gets one line per event.
|
||||
pub fn serve(
|
||||
listener: TcpListener,
|
||||
model: ModelId,
|
||||
tile: usize,
|
||||
device: String,
|
||||
run: RunTile,
|
||||
opts: ServerOptions,
|
||||
log: Arc<dyn Fn(&str) + Send + Sync>,
|
||||
) -> Result<(), String> {
|
||||
if opts.token.trim().chars().count() < MIN_TOKEN {
|
||||
return Err(format!("the token must be at least {MIN_TOKEN} characters"));
|
||||
}
|
||||
if tile == 0 || tile > MAX_TILE {
|
||||
return Err(format!("tile size {tile} is outside 1..={MAX_TILE}"));
|
||||
}
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let shared = Arc::new((model, device));
|
||||
for conn in listener.incoming() {
|
||||
let Ok(stream) = conn else { continue };
|
||||
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into());
|
||||
if active.load(Ordering::SeqCst) >= opts.max_connections.max(1) {
|
||||
let mut s = stream;
|
||||
let _ = writeln!(s, "{}", json!({"ok": false, "error": "server busy"}));
|
||||
log(&format!("{peer}: refused (busy)"));
|
||||
continue;
|
||||
}
|
||||
active.fetch_add(1, Ordering::SeqCst);
|
||||
let (sh, r, o, l, n) = (shared.clone(), run.clone(), opts.clone(), log.clone(), active.clone());
|
||||
let spawned = std::thread::Builder::new().name(format!("denoise {peer}")).spawn(move || {
|
||||
if let Err(e) = handle(stream, &sh.0, tile, &sh.1, &*r, &o, &*l) {
|
||||
l(&format!("{peer}: {e}"));
|
||||
}
|
||||
n.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
log("could not start a connection thread");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(
|
||||
stream: TcpStream,
|
||||
model: &ModelId,
|
||||
tile: usize,
|
||||
device: &str,
|
||||
run: &dyn Fn(&[f32]) -> Result<Vec<f32>, Error>,
|
||||
opts: &ServerOptions,
|
||||
log: &dyn Fn(&str),
|
||||
) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(opts.timeout))?;
|
||||
stream.set_write_timeout(Some(opts.timeout))?;
|
||||
stream.set_nodelay(true)?;
|
||||
let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
|
||||
let mut r = BufReader::new(stream.try_clone()?);
|
||||
let mut w = stream;
|
||||
let mut tiles = 0usize;
|
||||
let fail = |w: &mut TcpStream, msg: &str| writeln!(w, "{}", json!({"ok": false, "error": msg}));
|
||||
while let Some(line) = read_line(&mut r)? {
|
||||
let Ok(req) = serde_json::from_str::<Value>(&line) else {
|
||||
fail(&mut w, "malformed request")?;
|
||||
break;
|
||||
};
|
||||
if req.get("v").and_then(Value::as_u64) != Some(PROTOCOL) {
|
||||
fail(&mut w, &format!("protocol version {PROTOCOL} only"))?;
|
||||
break;
|
||||
}
|
||||
if !same(req.get("token").and_then(Value::as_str).unwrap_or(""), &opts.token) {
|
||||
fail(&mut w, "bad token")?;
|
||||
log(&format!("{peer}: bad token"));
|
||||
break;
|
||||
}
|
||||
match req.get("op").and_then(Value::as_str) {
|
||||
Some("hello") => writeln!(w, "{}", json!({"ok": true, "v": PROTOCOL, "device": device, "model": model, "tile": tile}))?,
|
||||
Some("run") => {
|
||||
let (Some(t), Some(bytes)) = (field(&req, "tile"), field(&req, "bytes")) else {
|
||||
fail(&mut w, "missing tile size or payload size")?;
|
||||
break;
|
||||
};
|
||||
if t != tile {
|
||||
fail(&mut w, &format!("this server runs {tile}-cell tiles, not {t}"))?;
|
||||
break;
|
||||
}
|
||||
// the payload is read before any refusal below, so the stream stays in step
|
||||
let input = match read_payload(&mut r, bytes, 4 * t * t) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
fail(&mut w, &e.to_string())?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if req.get("model").and_then(Value::as_str) != Some(model.sha256.as_str()) {
|
||||
fail(&mut w, &format!("model mismatch: this server runs {} {}", model.id, model.version))?;
|
||||
continue;
|
||||
}
|
||||
match run(&input) {
|
||||
Ok(out) if out.len() == 3 * 4 * t * t && out.iter().all(|v| v.is_finite()) => {
|
||||
write_payload(&mut w, json!({"ok": true, "tile": t}), &out)?;
|
||||
tiles += 1;
|
||||
}
|
||||
Ok(_) => fail(&mut w, "the model's answer has the wrong size or numbers that are not finite")?,
|
||||
Err(e) => fail(&mut w, &e.to_string())?,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
fail(&mut w, "unknown op")?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.flush()?;
|
||||
}
|
||||
if tiles > 0 {
|
||||
log(&format!("{peer}: {tiles} tiles"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a server said in its greeting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub address: String,
|
||||
pub device: String,
|
||||
pub model: ModelId,
|
||||
pub tile: usize,
|
||||
}
|
||||
|
||||
/// Tiles the server may get wrong (an error, a broken connection) before it stops being used.
|
||||
pub const MAX_FAILURES: usize = 3;
|
||||
|
||||
/// A server as a [`TileRunner`], for one model: keeps up to `slots` connections open and gives up on the server after
|
||||
/// [`MAX_FAILURES`] failed tiles (the caller runs those tiles itself).
|
||||
pub struct Remote {
|
||||
address: String,
|
||||
token: String,
|
||||
model_sha256: String,
|
||||
tile: usize,
|
||||
slots: usize,
|
||||
timeout: Duration,
|
||||
busy: AtomicUsize,
|
||||
failures: AtomicUsize,
|
||||
last_error: Mutex<Option<String>>,
|
||||
pool: Mutex<Vec<(BufReader<TcpStream>, TcpStream)>>,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// `address` as `host[:port]` (default port [`DEFAULT_PORT`]; `tcp://` and a trailing `/` are ignored).
|
||||
pub fn new(address: &str, token: &str, model_sha256: &str, tile: usize) -> Remote {
|
||||
let a = address.trim().trim_start_matches("tcp://").trim_end_matches('/');
|
||||
let has_port = a.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && !h.ends_with(':') && p.parse::<u16>().is_ok()) && !a.ends_with(']');
|
||||
let address = if has_port { a.to_string() } else { format!("{a}:{DEFAULT_PORT}") };
|
||||
Remote {
|
||||
address,
|
||||
token: token.into(),
|
||||
model_sha256: model_sha256.to_ascii_lowercase(),
|
||||
tile,
|
||||
slots: 4,
|
||||
timeout: Duration::from_secs(60),
|
||||
busy: AtomicUsize::new(0),
|
||||
failures: AtomicUsize::new(0),
|
||||
last_error: Mutex::new(None),
|
||||
pool: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_slots(mut self, n: usize) -> Remote {
|
||||
self.slots = n.clamp(1, 16);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn address(&self) -> &str {
|
||||
&self.address
|
||||
}
|
||||
|
||||
/// Tiles it can take at once.
|
||||
pub fn slots(&self) -> usize {
|
||||
self.slots
|
||||
}
|
||||
|
||||
/// Still used (fewer than [`MAX_FAILURES`] failed tiles).
|
||||
pub fn healthy(&self) -> bool {
|
||||
self.failures.load(Ordering::Relaxed) < MAX_FAILURES
|
||||
}
|
||||
|
||||
pub fn failures(&self) -> usize {
|
||||
self.failures.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Why the last tile failed.
|
||||
pub fn last_error(&self) -> Option<String> {
|
||||
self.last_error.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn err(&self, reason: impl std::fmt::Display) -> Error {
|
||||
Error::Runtime(format!("remote {}: {reason}", self.address))
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<(BufReader<TcpStream>, TcpStream), Error> {
|
||||
let addr = self.address.to_socket_addrs().map_err(|e| self.err(e))?.next().ok_or_else(|| self.err("the address doesn't resolve"))?;
|
||||
let s = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| self.err(e))?;
|
||||
s.set_read_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
|
||||
s.set_write_timeout(Some(self.timeout)).map_err(|e| self.err(e))?;
|
||||
s.set_nodelay(true).map_err(|e| self.err(e))?;
|
||||
let r = BufReader::new(s.try_clone().map_err(|e| self.err(e))?);
|
||||
Ok((r, s))
|
||||
}
|
||||
|
||||
fn reply(&self, r: &mut BufReader<TcpStream>) -> Result<Value, Error> {
|
||||
let line = read_line(r).map_err(|e| self.err(e))?.ok_or_else(|| self.err("the connection closed"))?;
|
||||
let v: Value = serde_json::from_str(&line).map_err(|_| self.err("malformed reply"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(self.err(v.get("error").and_then(Value::as_str).unwrap_or("request failed")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Greet the server: its device and model. Fails when it runs another model or tile size than this one.
|
||||
pub fn hello(&self) -> Result<ServerInfo, Error> {
|
||||
let (mut r, mut s) = self.connect()?;
|
||||
writeln!(s, "{}", json!({"v": PROTOCOL, "token": self.token, "op": "hello"})).map_err(|e| self.err(e))?;
|
||||
let v = self.reply(&mut r)?;
|
||||
let m = v.get("model").cloned().unwrap_or_default();
|
||||
let text = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let info = ServerInfo {
|
||||
address: self.address.clone(),
|
||||
device: text(&v, "device"),
|
||||
model: ModelId { id: text(&m, "id"), version: text(&m, "version"), sha256: text(&m, "sha256") },
|
||||
tile: field(&v, "tile").unwrap_or(0),
|
||||
};
|
||||
if !info.model.sha256.eq_ignore_ascii_case(&self.model_sha256) {
|
||||
return Err(self.err(format!("it runs another model ({} {})", info.model.id, info.model.version)));
|
||||
}
|
||||
if info.tile != self.tile {
|
||||
return Err(self.err(format!("it runs {}-cell tiles, this model {}", info.tile, self.tile)));
|
||||
}
|
||||
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Run a tile if a slot is free right now; `None` when all are busy or the server was given up (the caller runs
|
||||
/// the tile elsewhere). A failure counts towards giving up on the server.
|
||||
pub fn try_run(&self, input: &[f32]) -> Option<Result<Vec<f32>, Error>> {
|
||||
if !self.healthy() {
|
||||
return None;
|
||||
}
|
||||
if self.busy.fetch_add(1, Ordering::SeqCst) >= self.slots {
|
||||
self.busy.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
let r = self.run_now(input);
|
||||
self.busy.fetch_sub(1, Ordering::SeqCst);
|
||||
if let Err(e) = &r {
|
||||
self.failures.fetch_add(1, Ordering::Relaxed);
|
||||
*self.last_error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.to_string());
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
fn run_now(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let t = self.tile;
|
||||
if input.len() != 4 * t * t {
|
||||
return Err(Error::Input(format!("a tile of {} samples for {t}-cell tiles", input.len())));
|
||||
}
|
||||
let pooled = self.pool.lock().unwrap_or_else(PoisonError::into_inner).pop();
|
||||
let (mut r, mut s) = match pooled {
|
||||
Some(c) => c,
|
||||
None => self.connect()?,
|
||||
};
|
||||
let req = json!({"v": PROTOCOL, "token": self.token, "op": "run", "model": self.model_sha256, "tile": t});
|
||||
write_payload(&mut s, req, input).map_err(|e| self.err(e))?;
|
||||
s.flush().map_err(|e| self.err(e))?;
|
||||
let v = self.reply(&mut r)?;
|
||||
if field(&v, "tile") != Some(t) {
|
||||
return Err(self.err("the answer is for another tile size"));
|
||||
}
|
||||
let bytes = field(&v, "bytes").ok_or_else(|| self.err("an answer without its size"))?;
|
||||
let out = read_payload(&mut r, bytes, 3 * 4 * t * t).map_err(|e| self.err(e))?;
|
||||
self.pool.lock().unwrap_or_else(PoisonError::into_inner).push((r, s));
|
||||
// the other computer's numbers go straight into the render: NaN or infinity (an f16 can carry
|
||||
// both) is a failed tile, which `Shared` runs here instead
|
||||
if !out.iter().all(|v| v.is_finite()) {
|
||||
return Err(self.err("the answer holds values that aren't finite"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiles on the server when one of its slots is free, else (and for any tile it fails) on `local`: the server adds
|
||||
/// speed to this computer's runner instead of replacing it.
|
||||
pub struct Shared<'a> {
|
||||
pub remote: &'a Remote,
|
||||
pub local: &'a dyn TileRunner,
|
||||
}
|
||||
|
||||
impl TileRunner for Shared<'_> {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
match self.remote.try_run(input) {
|
||||
Some(Ok(out)) => Ok(out),
|
||||
Some(Err(_)) | None => self.local.run(input),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef-test";
|
||||
const T: usize = 8;
|
||||
|
||||
/// A stand-in model: each output pixel is its cell's first plane, times `k`.
|
||||
fn model(k: f32) -> RunTile {
|
||||
Arc::new(move |input: &[f32]| {
|
||||
let t = T;
|
||||
let mut out = vec![0f32; 3 * 4 * t * t];
|
||||
for c in 0..3 {
|
||||
for y in 0..2 * t {
|
||||
for x in 0..2 * t {
|
||||
out[(c * 2 * t + y) * 2 * t + x] = input[(y / 2) * t + x / 2] * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn id(sha: &str) -> ModelId {
|
||||
ModelId { id: "test".into(), version: "1".into(), sha256: sha.into() }
|
||||
}
|
||||
|
||||
fn start(sha: &str, run: RunTile) -> String {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = l.local_addr().unwrap().to_string();
|
||||
let opts = ServerOptions { token: TOKEN.into(), ..Default::default() };
|
||||
let m = id(sha);
|
||||
std::thread::spawn(move || serve(l, m, T, "CPU (test)".into(), run, opts, Arc::new(|_| {})));
|
||||
addr
|
||||
}
|
||||
|
||||
struct Local(RunTile, AtomicUsize);
|
||||
impl TileRunner for Local {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
self.1.fetch_add(1, Ordering::SeqCst);
|
||||
(self.0)(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_input(seed: f32) -> Vec<f32> {
|
||||
(0..4 * T * T).map(|i| (i as f32 * 0.37 + seed).sin() * 0.5 + 0.5).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_tiles_match_local_ones_within_half_float_rounding() {
|
||||
let addr = start("aa11", model(2.0));
|
||||
let remote = Remote::new(&addr, TOKEN, "AA11", T);
|
||||
let info = remote.hello().unwrap();
|
||||
assert_eq!((info.device.as_str(), info.tile), ("CPU (test)", T));
|
||||
let input = tile_input(0.3);
|
||||
let got = remote.try_run(&input).unwrap().unwrap();
|
||||
let want = model(2.0)(&input).unwrap();
|
||||
assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 2e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_runner_falls_back_and_gives_up_on_a_failing_server() {
|
||||
let local = Local(model(2.0), AtomicUsize::new(0));
|
||||
// nobody listening: every tile runs locally, and after a few the server is given up
|
||||
let dead = Remote::new("127.0.0.1:1", TOKEN, "aa11", T);
|
||||
let shared = Shared { remote: &dead, local: &local };
|
||||
for i in 0..6 {
|
||||
assert!(shared.run(&tile_input(i as f32)).is_ok());
|
||||
}
|
||||
assert_eq!(local.1.load(Ordering::SeqCst), 6);
|
||||
assert!(!dead.healthy() && dead.last_error().is_some());
|
||||
// a server with another model is refused at the greeting and per tile
|
||||
let addr = start("bb22", model(2.0));
|
||||
let other = Remote::new(&addr, TOKEN, "aa11", T);
|
||||
assert!(other.hello().unwrap_err().to_string().contains("another model"));
|
||||
assert!(other.try_run(&tile_input(0.0)).unwrap().unwrap_err().to_string().contains("model mismatch"));
|
||||
// a wrong token
|
||||
let addr = start("aa11", model(2.0));
|
||||
assert!(Remote::new(&addr, "wrong-token-0000000", "aa11", T).hello().unwrap_err().to_string().contains("bad token"));
|
||||
// all slots busy: the tile runs locally without waiting
|
||||
let busy = Remote::new(&addr, TOKEN, "aa11", T).with_slots(1);
|
||||
busy.busy.store(1, Ordering::SeqCst);
|
||||
assert!(busy.try_run(&tile_input(0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_requests_are_refused_and_the_server_keeps_working() {
|
||||
let addr = start("aa11", model(1.0));
|
||||
let talk = |bytes: &[u8]| -> String {
|
||||
let mut s = TcpStream::connect(&addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(bytes).unwrap();
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(s).read_line(&mut line);
|
||||
line
|
||||
};
|
||||
assert!(talk(b"not json\n").contains("malformed"));
|
||||
let v2 = format!("{}\n", json!({"v": 2, "token": TOKEN, "op": "hello"}));
|
||||
assert!(talk(v2.as_bytes()).contains("protocol"));
|
||||
let big = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": 4096, "bytes": 10}));
|
||||
assert!(talk(big.as_bytes()).contains("8-cell"));
|
||||
// a payload that inflates past its tile
|
||||
let bomb = miniz_oxide::deflate::compress_to_vec(&vec![0u8; 1 << 20], 9);
|
||||
let hdr = format!("{}\n", json!({"v": 1, "token": TOKEN, "op": "run", "model": "aa11", "tile": T, "bytes": bomb.len()}));
|
||||
assert!(talk(&[hdr.as_bytes(), &bomb].concat()).contains("false"));
|
||||
assert!(Remote::new(&addr, TOKEN, "aa11", T).hello().is_ok());
|
||||
assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "abcd"));
|
||||
// a short token is refused when the server starts
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let opts = ServerOptions { token: "short".into(), ..Default::default() };
|
||||
assert!(serve(l, id("aa11"), T, "x".into(), model(1.0), opts, Arc::new(|_| {})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_round_trips_and_addresses_are_normalised() {
|
||||
let v: Vec<f32> = (0..4096).map(|i| 0.5 + ((i * 7919 % 101) as f32 - 50.0) * 0.002).collect();
|
||||
let back = unpack(&pack(&v), v.len()).unwrap();
|
||||
assert!(v.iter().zip(&back).all(|(a, b)| (a - b).abs() < 1e-3));
|
||||
assert!(unpack(&pack(&v), v.len() + 1).is_err());
|
||||
assert_eq!(Remote::new("tcp://100.1.2.3/", TOKEN, "a", T).address(), "100.1.2.3:7990");
|
||||
assert_eq!(Remote::new("box:8000", TOKEN, "a", T).address(), "box:8000");
|
||||
assert_eq!(Remote::new("[::1]", TOKEN, "a", T).address(), "[::1]:7990");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -140,6 +177,13 @@ controls! {
|
||||
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain;
|
||||
"hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
@@ -501,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));
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,21 @@ impl DevelopSettings {
|
||||
serde_json::from_value(v.clone())
|
||||
}
|
||||
|
||||
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
|
||||
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
|
||||
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
|
||||
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
|
||||
/// Kelvin settings.
|
||||
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
|
||||
let mut v = self.to_json();
|
||||
presets::deep_merge(&mut v, partial);
|
||||
DevelopSettings::from_json(&v)
|
||||
let mut out = DevelopSettings::from_json(&v)?;
|
||||
if let Some(wb) = partial.get("wb").and_then(Value::as_object)
|
||||
&& (wb.contains_key("temp") || wb.contains_key("tint"))
|
||||
&& !wb.contains_key("scale")
|
||||
{
|
||||
out.wb.scale = WbScale::Legacy;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
|
||||
@@ -56,11 +66,13 @@ impl DevelopSettings {
|
||||
h
|
||||
}
|
||||
|
||||
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
|
||||
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values, and
|
||||
/// the rendering process: that is not an edit).
|
||||
pub fn is_unedited(&self) -> bool {
|
||||
let mut a = self.clone();
|
||||
let d = DevelopSettings::default();
|
||||
a.wb = d.wb;
|
||||
a.process = d.process;
|
||||
a == d
|
||||
}
|
||||
|
||||
@@ -130,6 +142,9 @@ impl DevelopSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_process;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -162,6 +177,68 @@ mod tests {
|
||||
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() {
|
||||
// settings from before the scale existed: no field, `Legacy`, and written back without it
|
||||
let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}});
|
||||
let s = DevelopSettings::from_json(&old).unwrap();
|
||||
assert_eq!(s.wb.scale, WbScale::Legacy);
|
||||
assert_eq!(s.to_json()["wb"], old["wb"]);
|
||||
assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy);
|
||||
assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none());
|
||||
// written on the Kelvin scale: said so, and read back
|
||||
let mut k = s.clone();
|
||||
k.wb.scale = WbScale::Kelvin;
|
||||
assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin"));
|
||||
assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k);
|
||||
assert_ne!(k.hash64(), s.hash64());
|
||||
// an unknown scale from a newer build is an error, not a panic
|
||||
assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_white_balance_without_a_scale_is_legacy() {
|
||||
let mut base = DevelopSettings::default();
|
||||
base.wb.scale = WbScale::Kelvin;
|
||||
// an old preset's white balance lands on Kelvin settings: it keeps its relative meaning
|
||||
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0);
|
||||
assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0));
|
||||
let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Legacy);
|
||||
// one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was
|
||||
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Kelvin);
|
||||
let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Kelvin);
|
||||
let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0);
|
||||
assert_eq!(out.wb.scale, WbScale::Kelvin);
|
||||
// the plain merge (`develop.merge`) follows the same rule
|
||||
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy);
|
||||
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin);
|
||||
assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
|
||||
let s = DevelopSettings::default();
|
||||
assert!(s.to_json().get("hdr").is_none(), "HDR off must not change the settings JSON or hash64");
|
||||
let mut on = s.clone();
|
||||
on.hdr.enabled = true;
|
||||
assert!(on.to_json().get("hdr").is_some());
|
||||
assert_ne!(on.hash64(), s.hash64());
|
||||
assert_eq!(DevelopSettings::from_json(&on.to_json()).unwrap(), on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_peak_is_finite_for_a_non_finite_headroom() {
|
||||
let mut h = Hdr { enabled: true, max_ev: f64::NAN, ..Hdr::default() };
|
||||
assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
|
||||
h.max_ev = f64::INFINITY;
|
||||
assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2());
|
||||
h.max_ev = -3.0;
|
||||
assert_eq!(h.peak(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_take_defaults_and_unknown_ignored() {
|
||||
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it
|
||||
//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current
|
||||
//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]).
|
||||
//!
|
||||
//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets
|
||||
//! made from a photo never carry it, so the photos they change keep their own. A partial that names
|
||||
//! `process` itself (Apply Settings JSON) sets it like any other field.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
@@ -98,7 +102,7 @@ impl SettingsGroup {
|
||||
SettingsGroup::Profile => &["profile"],
|
||||
SettingsGroup::Treatment => &["treatment"],
|
||||
SettingsGroup::WhiteBalance => &["wb"],
|
||||
SettingsGroup::Light => &["light"],
|
||||
SettingsGroup::Light => &["light", "hdr"],
|
||||
SettingsGroup::ToneCurve => &["curve"],
|
||||
SettingsGroup::Color => &["color"],
|
||||
SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"],
|
||||
@@ -162,7 +166,9 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
(Value::Number(b), Value::Number(p)) => {
|
||||
// Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would
|
||||
// not deserialize as an integer, and the whole preset would silently not apply.
|
||||
(Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => {
|
||||
let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0));
|
||||
serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null)
|
||||
}
|
||||
@@ -181,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
|
||||
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
||||
let base = s.to_json();
|
||||
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
|
||||
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
|
||||
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
|
||||
for c in crate::controls::CONTROLS {
|
||||
if let Some(v) = crate::controls::get(&out, c.id) {
|
||||
@@ -267,6 +274,25 @@ mod tests {
|
||||
assert_eq!(lots.light.exposure, 5.0);
|
||||
}
|
||||
|
||||
/// A preset with the Grain group (its seed is an integer) used to do nothing at any amount
|
||||
/// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped.
|
||||
#[test]
|
||||
fn amount_keeps_integer_fields_whole() {
|
||||
let mut a = DevelopSettings::default();
|
||||
a.light.exposure = 1.0;
|
||||
a.grain.amount = 40.0;
|
||||
a.grain.seed = 12345;
|
||||
let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
|
||||
assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed");
|
||||
let base = DevelopSettings::default();
|
||||
for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] {
|
||||
let out = p.apply(&base, amount);
|
||||
assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure);
|
||||
assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount);
|
||||
assert_eq!(out.grain.seed, seed, "{amount}: seed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_merge_objects() {
|
||||
let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});
|
||||
|
||||
@@ -1,19 +1,122 @@
|
||||
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
|
||||
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
|
||||
//! older/newer files load (unknown fields are ignored, missing fields take defaults). The one
|
||||
//! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were
|
||||
//! made with.
|
||||
|
||||
use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// The rendering process a photo's settings are interpreted by (what Lightroom calls the process
|
||||
/// version). A change that would alter how existing settings render ships as a new process, so a
|
||||
/// photo keeps its look until someone updates it (`develop.updateProcess`). See
|
||||
/// `docs/process-versions.md`.
|
||||
///
|
||||
/// Stored as a plain number: one written by a newer LightCraft loads, is kept as it is, and
|
||||
/// renders with the newest process this build knows ([`ProcessVersion::process`]). Reading is
|
||||
/// lenient: a whole number from 0 to 2^32 - 1 (also written as `2.0`) is that process; anything
|
||||
/// else (negative, fractional, too large, text, null, a list…) reads as V1, like a missing field,
|
||||
/// so a damaged value never costs the rest of the settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProcessVersion(pub u32);
|
||||
|
||||
impl<'de> Deserialize<'de> for ProcessVersion {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Wire {
|
||||
Whole(u64),
|
||||
Number(f64),
|
||||
Other(serde::de::IgnoredAny),
|
||||
}
|
||||
Ok(match Wire::deserialize(d)? {
|
||||
Wire::Whole(n) => u32::try_from(n).map_or_else(|_| ProcessVersion::legacy(), ProcessVersion),
|
||||
Wire::Number(x) if x.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&x) => ProcessVersion(x as u32),
|
||||
Wire::Number(_) | Wire::Other(_) => ProcessVersion::legacy(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessVersion {
|
||||
/// The first process: LightCraft's rendering from before process versions existed. Frozen.
|
||||
pub const V1: ProcessVersion = Process::V1.version();
|
||||
/// The process new photos and Reset get.
|
||||
pub const LATEST: ProcessVersion = Process::LATEST.version();
|
||||
|
||||
/// The process of settings saved without one (catalogs, snapshots, XMP sidecars and browser
|
||||
/// libraries written before process versions existed): V1, whatever [`Self::LATEST`] becomes.
|
||||
pub fn legacy() -> ProcessVersion {
|
||||
ProcessVersion::V1
|
||||
}
|
||||
|
||||
/// Saved settings leave the field out for V1, so files and preview-cache keys of settings made
|
||||
/// before process versions existed stay byte for byte what they were.
|
||||
pub fn is_legacy(&self) -> bool {
|
||||
*self == ProcessVersion::legacy()
|
||||
}
|
||||
|
||||
/// The process this build renders with: the newest one it knows that isn't newer than this
|
||||
/// number (a number from a newer LightCraft renders with [`Process::LATEST`]; one below V1,
|
||||
/// which no LightCraft writes, with V1).
|
||||
pub fn process(self) -> Process {
|
||||
Process::ALL.into_iter().rev().find(|p| p.version() <= self).unwrap_or(Process::V1)
|
||||
}
|
||||
|
||||
/// A process this build implements.
|
||||
pub fn is_known(self) -> bool {
|
||||
Process::ALL.iter().any(|p| p.version() == self)
|
||||
}
|
||||
|
||||
/// Older than [`Self::LATEST`]: Update to Current Process applies.
|
||||
pub fn is_outdated(self) -> bool {
|
||||
self < ProcessVersion::LATEST
|
||||
}
|
||||
}
|
||||
|
||||
/// A rendering process this build implements. Matches on it are exhaustive, so adding a process
|
||||
/// makes the compiler point at every stage whose behaviour depends on it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Process {
|
||||
/// The rendering of LightCraft before process versions existed (2026-10).
|
||||
V1,
|
||||
/// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base
|
||||
/// is taken of a fine median of log luminance (issue #632).
|
||||
V2,
|
||||
}
|
||||
|
||||
impl Process {
|
||||
/// Every process, oldest first.
|
||||
pub const ALL: [Process; 2] = [Process::V1, Process::V2];
|
||||
pub const LATEST: Process = Process::V2;
|
||||
|
||||
pub const fn version(self) -> ProcessVersion {
|
||||
match self {
|
||||
Process::V1 => ProcessVersion(1),
|
||||
Process::V2 => ProcessVersion(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DevelopSettings {
|
||||
/// Schema version of this structure (not the rendering process, see `process`).
|
||||
pub version: u32,
|
||||
/// The rendering process ([`ProcessVersion`]): the latest for new settings, V1 for settings
|
||||
/// saved without the field (the field-level default: not the struct's `Default`) or with a
|
||||
/// value that isn't a process number.
|
||||
#[serde(default = "ProcessVersion::legacy", skip_serializing_if = "ProcessVersion::is_legacy")]
|
||||
pub process: ProcessVersion,
|
||||
pub profile: Profile,
|
||||
pub treatment: Treatment,
|
||||
pub wb: WhiteBalance,
|
||||
pub light: Light,
|
||||
/// HDR editing: highlights above SDR white, and the SDR rendition derived from them. Not written while it is
|
||||
/// the default, so settings without HDR keep their JSON and `hash64` (and every cached preview stays valid).
|
||||
#[serde(skip_serializing_if = "Hdr::is_default")]
|
||||
pub hdr: Hdr,
|
||||
pub curve: ToneCurve,
|
||||
pub color: ColorAdj,
|
||||
pub mixer: Mixer,
|
||||
@@ -44,10 +147,12 @@ impl Default for DevelopSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SCHEMA_VERSION,
|
||||
process: ProcessVersion::LATEST,
|
||||
profile: Profile::default(),
|
||||
treatment: Treatment::Color,
|
||||
wb: WhiteBalance::default(),
|
||||
light: Light::default(),
|
||||
hdr: Hdr::default(),
|
||||
curve: ToneCurve::default(),
|
||||
color: ColorAdj::default(),
|
||||
mixer: Mixer::default(),
|
||||
@@ -141,7 +246,13 @@ impl WbMode {
|
||||
WbMode::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
/// Preset temperature/tint for raw files (K, tint).
|
||||
/// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 /
|
||||
/// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for
|
||||
/// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction
|
||||
/// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta
|
||||
/// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same
|
||||
/// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws
|
||||
/// without matrices) `develop.wb` scales them around 6500 K = as shot.
|
||||
pub fn preset(self) -> Option<(f64, f64)> {
|
||||
match self {
|
||||
WbMode::Daylight => Some((5500.0, 10.0)),
|
||||
@@ -155,9 +266,35 @@ impl WbMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
|
||||
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
|
||||
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
|
||||
/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on.
|
||||
///
|
||||
/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin;
|
||||
/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0
|
||||
/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them
|
||||
/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's
|
||||
/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the
|
||||
/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WbScale {
|
||||
/// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot)
|
||||
/// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written.
|
||||
#[default]
|
||||
Legacy,
|
||||
/// Absolute Kelvin and tint, as Lightroom writes them for raws.
|
||||
Kelvin,
|
||||
}
|
||||
|
||||
impl WbScale {
|
||||
fn is_legacy(&self) -> bool {
|
||||
*self == WbScale::Legacy
|
||||
}
|
||||
}
|
||||
|
||||
/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the
|
||||
/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is
|
||||
/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a
|
||||
/// relative −100..100 scale for those, like Lightroom).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WhiteBalance {
|
||||
@@ -165,11 +302,17 @@ pub struct WhiteBalance {
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
/// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings
|
||||
/// from before it existed read and write unchanged.
|
||||
#[serde(skip_serializing_if = "WbScale::is_legacy")]
|
||||
pub scale: WbScale,
|
||||
}
|
||||
|
||||
impl Default for WhiteBalance {
|
||||
fn default() -> Self {
|
||||
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
|
||||
// (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the
|
||||
// scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`)
|
||||
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,6 +698,33 @@ impl Default for Optics {
|
||||
pub struct EmbeddedLens {
|
||||
pub warp: Option<EmbeddedWarp>,
|
||||
pub vignette: Option<EmbeddedVignette>,
|
||||
/// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps
|
||||
/// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode.
|
||||
pub stages: [Option<EmbeddedWarpStage>; WARP_STAGES],
|
||||
}
|
||||
|
||||
/// Most warp stages an [`EmbeddedLens`] chain keeps.
|
||||
pub const WARP_STAGES: usize = 3;
|
||||
|
||||
/// One DNG `WarpRectilinear2` (or a `WarpRectilinear` in a chain with one): per plane (R, G, B)
|
||||
/// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]`. For an output point at offset
|
||||
/// `d = (p − center) / radius` with `r = |d|` and `f = Σ krₚ·clamp(r, min, max)ᵖ`, the source point is
|
||||
/// `center + radius · (d·f + tangential(d))`, or `d / f` for the radial part when `reciprocal`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddedWarpStage {
|
||||
pub planes: [[f64; 19]; 3],
|
||||
/// Optical centre, normalized to the oriented image (0..1).
|
||||
pub center: Point,
|
||||
/// Normalisation radius as a fraction of the oriented image's long edge.
|
||||
pub radius: f64,
|
||||
pub reciprocal: bool,
|
||||
}
|
||||
|
||||
impl EmbeddedLens {
|
||||
/// Whether a `WarpRectilinear2` chain is set.
|
||||
pub fn has_stages(&self) -> bool {
|
||||
self.stages.iter().any(Option::is_some)
|
||||
}
|
||||
}
|
||||
|
||||
/// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at
|
||||
@@ -862,6 +1032,8 @@ pub enum SpotMode {
|
||||
Remove,
|
||||
Heal,
|
||||
Clone,
|
||||
/// Content-aware: the spot is rebuilt from patches of its surroundings (no single source).
|
||||
Fill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -931,3 +1103,78 @@ impl Enhance {
|
||||
self.denoise_on.unwrap_or(self.denoise > 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0)
|
||||
/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's
|
||||
/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the
|
||||
/// `sdr_*` offsets applied.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Hdr {
|
||||
pub enabled: bool,
|
||||
/// Headroom limit: stops above SDR white the HDR render may reach.
|
||||
pub max_ev: f64,
|
||||
/// SDR rendition offsets (−100..100): brightness (±1 EV), then added to the matching sliders.
|
||||
pub sdr_brightness: f64,
|
||||
pub sdr_contrast: f64,
|
||||
pub sdr_highlights: f64,
|
||||
pub sdr_shadows: f64,
|
||||
pub sdr_whites: f64,
|
||||
pub sdr_clarity: f64,
|
||||
}
|
||||
|
||||
impl Default for Hdr {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
max_ev: Hdr::DEFAULT_MAX_EV,
|
||||
sdr_brightness: 0.0,
|
||||
sdr_contrast: 0.0,
|
||||
sdr_highlights: 0.0,
|
||||
sdr_shadows: 0.0,
|
||||
sdr_whites: 0.0,
|
||||
sdr_clarity: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub const DEFAULT_MAX_EV: f64 = 3.0;
|
||||
pub const MAX_EV_LIMIT: f64 = 5.0;
|
||||
|
||||
/// Whether these are the default HDR settings (HDR off, nothing changed).
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Hdr::default()
|
||||
}
|
||||
|
||||
/// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). A non-finite headroom
|
||||
/// (a damaged file or argument) falls back to the default, so the peak is always finite and at least 1.
|
||||
pub fn peak(&self) -> f32 {
|
||||
if !self.enabled {
|
||||
return 1.0;
|
||||
}
|
||||
let ev = if self.max_ev.is_finite() { self.max_ev } else { Hdr::DEFAULT_MAX_EV };
|
||||
(ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2()
|
||||
}
|
||||
}
|
||||
|
||||
impl DevelopSettings {
|
||||
/// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the
|
||||
/// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity.
|
||||
pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> {
|
||||
if !self.hdr.enabled {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let h = self.hdr;
|
||||
let mut s = self.clone();
|
||||
s.hdr.enabled = false;
|
||||
let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0);
|
||||
s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0);
|
||||
s.light.contrast = add(s.light.contrast, h.sdr_contrast);
|
||||
s.light.highlights = add(s.light.highlights, h.sdr_highlights);
|
||||
s.light.shadows = add(s.light.shadows, h.sdr_shadows);
|
||||
s.light.whites = add(s.light.whites, h.sdr_whites);
|
||||
s.effects.clarity = add(s.effects.clarity, h.sdr_clarity);
|
||||
std::borrow::Cow::Owned(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Process versions: settings saved before they existed stay on V1, new settings get the latest,
|
||||
//! and numbers this build doesn't know load without harm.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Complete settings as LightCraft wrote them before process versions existed (main at 8f924a9:
|
||||
/// `serde_json::to_string` of a raw photo with tone, colour, curve, grading, effects and a radial
|
||||
/// mask), one top-level key per line. There is no `process` field.
|
||||
const LEGACY: &str = r#"
|
||||
{
|
||||
"version": 1,
|
||||
"profile": {"id": "lc.color", "amount": 100.0},
|
||||
"treatment": "color",
|
||||
"wb": {"mode": "asShot", "temp": 5200.0, "tint": 4.0},
|
||||
"light": {"exposure": 0.4, "contrast": 30.0, "highlights": -60.0, "shadows": 40.0, "whites": 15.0, "blacks": -20.0},
|
||||
"curve": {"highlights": -10.0, "lights": 0.0, "darks": 10.0, "shadows": 0.0, "split_shadows": 25.0, "split_mid": 50.0, "split_highlights": 75.0, "master": [{"x": 0.0, "y": 0.05}, {"x": 0.5, "y": 0.55}, {"x": 1.0, "y": 0.95}], "red": [], "green": [], "blue": [], "refine_saturation": 100.0},
|
||||
"color": {"vibrance": 20.0, "saturation": -5.0},
|
||||
"mixer": {"red": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "orange": {"hue": 0.0, "sat": 0.0, "lum": 15.0}, "yellow": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "green": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "aqua": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blue": {"hue": 0.0, "sat": -30.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "magenta": {"hue": 0.0, "sat": 0.0, "lum": 0.0}},
|
||||
"point_colors": [],
|
||||
"bw_mix": {"red": 0.0, "orange": 0.0, "yellow": 0.0, "green": 0.0, "aqua": 0.0, "blue": 0.0, "purple": 0.0, "magenta": 0.0},
|
||||
"grading": {"shadows": {"hue": 220.0, "sat": 20.0, "lum": -5.0}, "midtones": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 15.0, "lum": 5.0}, "global": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blending": 50.0, "balance": 0.0},
|
||||
"effects": {"texture": 20.0, "clarity": 25.0, "dehaze": 15.0},
|
||||
"vignette": {"amount": -25.0, "midpoint": 50.0, "roundness": 0.0, "feather": 50.0, "highlights": 0.0, "style": "highlightPriority"},
|
||||
"grain": {"amount": 20.0, "size": 25.0, "roughness": 50.0, "seed": 0},
|
||||
"detail": {"sharpen_amount": 40.0, "sharpen_radius": 1.0, "sharpen_detail": 25.0, "sharpen_masking": 0.0, "nr_luminance": 20.0, "nr_detail": 50.0, "nr_contrast": 0.0, "nr_color": 25.0, "nr_color_detail": 50.0, "nr_color_smoothness": 50.0},
|
||||
"optics": {"remove_ca": false, "lens_profile": false, "profile_distortion": 100.0, "profile_vignetting": 100.0, "defringe_purple_amount": 0.0, "defringe_purple_hue_lo": 30.0, "defringe_purple_hue_hi": 70.0, "defringe_green_amount": 0.0, "defringe_green_hue_lo": 40.0, "defringe_green_hue_hi": 60.0, "distortion": 0.0, "vignetting": 0.0, "vignetting_midpoint": 50.0, "ca_red": 0.0, "ca_blue": 0.0},
|
||||
"geometry": {"upright": "off", "guides": [], "upright_transform": null, "vertical": 0.0, "horizontal": 0.0, "rotate": 0.0, "aspect": 0.0, "scale": 100.0, "offset_x": 0.0, "offset_y": 0.0, "constrain_crop": false},
|
||||
"crop": {"geometry": {"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}, "aspect": null, "flip_h": false, "flip_v": false},
|
||||
"orientation": "Normal",
|
||||
"masks": [{"id": 1, "name": "Mask 1", "visible": true, "invert": false, "components": [{"op": "add", "invert": false, "shape": {"kind": "radial", "center": {"x": 0.4, "y": 0.5}, "rx": 0.3, "ry": 0.2, "angle": 15.0, "feather": 60.0, "invert": false}}], "adjust": {"temp": 10.0, "tint": 0.0, "exposure": 0.5, "contrast": 0.0, "highlights": 0.0, "shadows": 0.0, "whites": 0.0, "blacks": 0.0, "texture": 0.0, "clarity": 30.0, "dehaze": 0.0, "hue": 0.0, "saturation": 0.0, "sharpness": 0.0, "noise": 0.0, "moire": 0.0, "defringe": 0.0, "color_hue": 0.0, "color_sat": 0.0, "amount": 100.0}}],
|
||||
"spots": [],
|
||||
"red_eye": [],
|
||||
"lens_blur": {"enabled": false, "amount": 0.0, "focal_distance": 0.0, "focal_range": 0.0},
|
||||
"enhance": {"denoise": 0.0, "raw_details": false, "super_resolution": false},
|
||||
"calibration": {"shadows_tint": 0.0, "red_hue": 0.0, "red_sat": 0.0, "green_hue": 0.0, "green_sat": 0.0, "blue_hue": 0.0, "blue_sat": 0.0},
|
||||
"disabled_sections": []
|
||||
}
|
||||
"#;
|
||||
|
||||
fn legacy() -> Value {
|
||||
serde_json::from_str(LEGACY).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_saved_before_process_versions_load_as_v1() {
|
||||
let v = legacy();
|
||||
assert!(v.get("process").is_none(), "the fixture predates the field");
|
||||
let s = DevelopSettings::from_json(&v).unwrap();
|
||||
// V1 as a number, not `LATEST`: once a newer process is the latest, this is what keeps every
|
||||
// existing catalog, sidecar and preset on the rendering it was made with
|
||||
assert_eq!(s.process, ProcessVersion(1));
|
||||
assert_eq!(s.process, ProcessVersion::legacy());
|
||||
assert_eq!((s.light.exposure, s.light.highlights, s.masks.len(), s.detail.sharpen_amount), (0.4, -60.0, 1, 40.0));
|
||||
// the smallest legacy file too, and settings nested in other records (history, versions)
|
||||
assert_eq!(DevelopSettings::from_json(&json!({})).unwrap().process, ProcessVersion(1));
|
||||
let nested: Vec<DevelopSettings> = serde_json::from_value(json!([{}, {"light": {"exposure": 1.0}}])).unwrap();
|
||||
assert!(nested.iter().all(|s| s.process == ProcessVersion(1)));
|
||||
// saved again, they gain no field: files and preview-cache keys (`hash64`) stay as they were
|
||||
assert!(s.to_json().get("process").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_settings_get_the_latest_process() {
|
||||
assert_eq!(DevelopSettings::default().process, ProcessVersion::LATEST);
|
||||
assert_eq!(DevelopSettings::for_raw(5200.0, 4.0).process, ProcessVersion::LATEST);
|
||||
assert_eq!(ProcessVersion::LATEST, Process::LATEST.version());
|
||||
assert_eq!(ProcessVersion::V1, ProcessVersion(1));
|
||||
// the processes are listed oldest first, the latest last, each with its own number
|
||||
let versions: Vec<u32> = Process::ALL.iter().map(|p| p.version().0).collect();
|
||||
assert!(versions.windows(2).all(|w| w[0] < w[1]), "{versions:?}");
|
||||
assert_eq!(Process::ALL.last(), Some(&Process::LATEST));
|
||||
assert!(Process::ALL.iter().all(|p| p.version().process() == *p && p.version().is_known()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_is_left_out_when_saving_and_other_processes_are_kept() {
|
||||
let mut s = DevelopSettings::from_json(&legacy()).unwrap();
|
||||
assert!(s.to_json().get("process").is_none());
|
||||
let before = s.hash64();
|
||||
s.process = ProcessVersion(2);
|
||||
let v = s.to_json();
|
||||
assert_eq!(v["process"], json!(2));
|
||||
assert_eq!(DevelopSettings::from_json(&v).unwrap().process, ProcessVersion(2));
|
||||
assert_ne!(s.hash64(), before, "another process is another preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_process_numbers_load_and_render_with_the_newest_known() {
|
||||
// written by a newer LightCraft: kept as it is, rendered with the latest process this build has
|
||||
let s = DevelopSettings::from_json(&json!({"process": 7, "light": {"exposure": 0.5}})).unwrap();
|
||||
assert_eq!(s.process, ProcessVersion(7));
|
||||
assert_eq!(s.process.process(), Process::LATEST);
|
||||
assert!(!s.process.is_known());
|
||||
assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process");
|
||||
assert_eq!(s.to_json()["process"], json!(7));
|
||||
assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(7));
|
||||
// below V1 (no LightCraft writes it): rendered as V1, and updatable
|
||||
let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process;
|
||||
assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true));
|
||||
let max = DevelopSettings::from_json(&json!({"process": u32::MAX})).unwrap().process;
|
||||
assert_eq!(max.process(), Process::LATEST);
|
||||
// a whole number written as a float is that number
|
||||
assert_eq!(DevelopSettings::from_json(&json!({"process": 2.0})).unwrap().process, ProcessVersion(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_process_value_reads_as_v1_and_keeps_the_edits() {
|
||||
let too_big = u64::from(u32::MAX) + 1;
|
||||
for bad in [
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!(-0.5),
|
||||
json!(1e300),
|
||||
json!(too_big),
|
||||
json!(u64::MAX),
|
||||
json!("2"),
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!([2]),
|
||||
json!({"v": 2}),
|
||||
] {
|
||||
let mut v = legacy();
|
||||
v["process"] = bad.clone();
|
||||
let s = DevelopSettings::from_json(&v).unwrap_or_else(|e| panic!("{bad}: {e}"));
|
||||
assert_eq!(s.process, ProcessVersion::V1, "{bad}");
|
||||
assert_eq!((s.light.exposure, s.masks.len(), s.curve.master.len()), (0.4, 1, 3), "{bad}: the rest of the settings load");
|
||||
// nested in other records too (history, versions, the catalog)
|
||||
let nested: Vec<DevelopSettings> = serde_json::from_value(json!([v])).unwrap();
|
||||
assert_eq!(nested[0].process, ProcessVersion::V1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_process_is_not_an_edit_nor_part_of_copied_settings() {
|
||||
let mut s = DevelopSettings { process: ProcessVersion(LATEST_PLUS_ONE), ..Default::default() };
|
||||
assert!(s.is_unedited(), "a photo on another process is not edited for that");
|
||||
s.light.exposure = 1.0;
|
||||
assert!(!s.is_unedited());
|
||||
// resetting a section or a slider leaves the process alone
|
||||
s.reset_section(Section::Light);
|
||||
assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE));
|
||||
// copy / paste / sync / presets: no group carries the process, so the target keeps its own
|
||||
assert!(SettingsGroup::ALL.iter().all(|g| !g.keys().contains(&"process")));
|
||||
s.light.exposure = 1.0;
|
||||
let copied = extract_groups(&s, &SettingsGroup::ALL);
|
||||
assert!(copied.get("process").is_none());
|
||||
let target = DevelopSettings::from_json(&legacy()).unwrap();
|
||||
let pasted = apply_partial(&target, &copied, 1.0);
|
||||
assert_eq!((pasted.process, pasted.light.exposure), (ProcessVersion(1), 1.0));
|
||||
// (at another amount, without Grain: its integer seed doesn't survive scaling, a separate issue)
|
||||
let groups: Vec<SettingsGroup> = SettingsGroup::ALL.into_iter().filter(|g| *g != SettingsGroup::Grain).collect();
|
||||
let preset = Preset::from_settings("p", "P", "User", &s, &groups);
|
||||
let half = preset.apply(&target, 0.5);
|
||||
assert_eq!((half.process, half.light.exposure), (ProcessVersion(1), 0.7));
|
||||
// a partial that names a process explicitly (Apply Settings JSON) sets it
|
||||
assert_eq!(target.merged(&json!({"process": LATEST_PLUS_ONE})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE));
|
||||
}
|
||||
|
||||
/// A process other than the latest: a newer LightCraft's number (no older one than V1 exists).
|
||||
const LATEST_PLUS_ONE: u32 = ProcessVersion::LATEST.0 + 1;
|
||||
@@ -24,9 +24,11 @@ lightcraft-raw = { workspace = true }
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true, optional = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-lensdb = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
lightcraft-geocode = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -49,5 +51,9 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
libc = { workspace = true }
|
||||
lightcraft-tiff = { workspace = true }
|
||||
@@ -247,18 +247,20 @@ fn main() {
|
||||
}
|
||||
}
|
||||
if run("batch") && args.len() > 1 {
|
||||
// full-size JPEG export of all the files given (decode + render + encode)
|
||||
use lightcraft_engine::export::export_photo;
|
||||
// full-size JPEG export of all the files given (decode + render + encode), as the Export
|
||||
// dialog runs it: several photos side by side (`export_parallelism`), files discarded
|
||||
use lightcraft_engine::export::{Destination, export_batch};
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import");
|
||||
let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect();
|
||||
let items: Vec<_> = ids.iter().enumerate().map(|(i, id)| (*id, i + 1)).collect();
|
||||
let o = ExportOptions::default();
|
||||
row(&format!("batch export of {} files (decode+render+JPEG):", items.len()), &mut |g| {
|
||||
let lanes = lightcraft_engine::export::export_parallelism(ids.len());
|
||||
row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| {
|
||||
lightcraft_engine::gpu::set_enabled(g);
|
||||
for &(id, seq) in &items {
|
||||
drop(export_photo(&mut session, id, &o, seq).expect("export"));
|
||||
session.media.forget(id); // as in a batch: every original is decoded once
|
||||
let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export");
|
||||
assert_eq!(files.len(), ids.len());
|
||||
for &id in &ids {
|
||||
session.media.forget(id); // every original is decoded once per run
|
||||
}
|
||||
});
|
||||
lightcraft_engine::gpu::set_enabled(true);
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Background tasks in flight (issue #345): imports, exports, preview builds, downloads, the face scan… Each one
|
||||
//! registers here while it runs, so every frontend can show them in one place (the desktop app's activity stack) and
|
||||
//! cancel the ones that can be cancelled (`activity.list`, `activity.cancel`).
|
||||
//!
|
||||
//! A job holds a [`TaskGuard`] for as long as it runs; dropping it — when the job ends, fails or its thread unwinds
|
||||
//! from a panic — removes the row, so a dead worker never leaves a stuck bar. Jobs keep the cancel flag they already
|
||||
//! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the
|
||||
//! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a
|
||||
//! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// The longest label or detail kept, in characters (a file name can be anything).
|
||||
const MAX_TEXT: usize = 200;
|
||||
|
||||
/// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Activity {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
/// Oldest first.
|
||||
tasks: Mutex<Vec<Arc<Entry>>>,
|
||||
next: AtomicU64,
|
||||
}
|
||||
|
||||
/// How a task can be cancelled.
|
||||
pub enum Cancel {
|
||||
/// Not at all (no ✕): the face scan, AI Denoise.
|
||||
No,
|
||||
/// With a flag of its own ([`TaskGuard::cancel_flag`]).
|
||||
Yes,
|
||||
/// With the job's existing flag.
|
||||
Flag(Arc<AtomicBool>),
|
||||
}
|
||||
|
||||
/// How the frontend words `done` / `total`: "3 of 25", "12 of 340 MB", "40 %".
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Unit {
|
||||
#[default]
|
||||
Count,
|
||||
Bytes,
|
||||
Percent,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
fn from_u8(v: u8) -> Unit {
|
||||
match v {
|
||||
1 => Unit::Bytes,
|
||||
2 => Unit::Percent,
|
||||
_ => Unit::Count,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
Unit::Count => 0,
|
||||
Unit::Bytes => 1,
|
||||
Unit::Percent => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One task as the frontends see it (`activity.list`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
pub done: u64,
|
||||
/// 0 while the amount of work isn't known yet (an indeterminate bar).
|
||||
pub total: u64,
|
||||
pub unit: Unit,
|
||||
/// The file, photo or model being worked on, if any.
|
||||
pub detail: String,
|
||||
pub cancellable: bool,
|
||||
/// Cancel was asked for; the job stops at its next check.
|
||||
pub cancelling: bool,
|
||||
pub age_ms: u64,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
id: u64,
|
||||
kind: &'static str,
|
||||
label: String,
|
||||
started: web_time::Instant,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
unit: AtomicU8,
|
||||
detail: Mutex<String>,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
cancellable: AtomicBool,
|
||||
/// Held while a cancel is decided ([`Activity::cancel`], [`Activity::cancel_all`]) and while
|
||||
/// cancelling is switched ([`TaskGuard::set_cancellable`], [`TaskHandle::set_cancellable`]): a
|
||||
/// step that turns it off and then reads the cancel flag either sees a cancel that was
|
||||
/// accepted, or makes the cancel fail. Without it a cancel could pass the check, the step
|
||||
/// start, and the flag be set after the step's last look at it: accepted but ignored.
|
||||
gate: Mutex<()>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn capped(text: &str) -> String {
|
||||
text.chars().take(MAX_TEXT).collect()
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn set_cancellable(&self, yes: bool) {
|
||||
let _gate = lock(&self.gate);
|
||||
self.cancellable.store(yes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set the cancel flag if the task can be cancelled now (see [`Entry::gate`]).
|
||||
fn try_cancel(&self) -> bool {
|
||||
let _gate = lock(&self.gate);
|
||||
match &self.cancel {
|
||||
Some(flag) if self.is_cancellable() => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancellable(&self) -> bool {
|
||||
self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn progress(&self, done: u64, total: u64) {
|
||||
self.total.store(total, Ordering::Relaxed);
|
||||
self.done.store(if total > 0 { done.min(total) } else { done }, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn detail(&self, text: &str) {
|
||||
*lock(&self.detail) = capped(text);
|
||||
}
|
||||
|
||||
fn info(&self) -> TaskInfo {
|
||||
TaskInfo {
|
||||
id: self.id,
|
||||
kind: self.kind,
|
||||
label: self.label.clone(),
|
||||
done: self.done.load(Ordering::Relaxed),
|
||||
total: self.total.load(Ordering::Relaxed),
|
||||
unit: Unit::from_u8(self.unit.load(Ordering::Relaxed)),
|
||||
detail: lock(&self.detail).clone(),
|
||||
cancellable: self.is_cancellable(),
|
||||
cancelling: self.is_cancelled(),
|
||||
age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
/// Register a task; it is listed until the returned guard is dropped. `label` is English (the frontend
|
||||
/// translates it).
|
||||
pub fn start(&self, kind: &'static str, label: &str, cancel: Cancel) -> TaskGuard {
|
||||
let id = self.inner.next.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
let cancel = match cancel {
|
||||
Cancel::No => None,
|
||||
Cancel::Yes => Some(Arc::new(AtomicBool::new(false))),
|
||||
Cancel::Flag(flag) => Some(flag),
|
||||
};
|
||||
let entry = Arc::new(Entry {
|
||||
id,
|
||||
kind,
|
||||
label: capped(label),
|
||||
started: web_time::Instant::now(),
|
||||
done: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
unit: AtomicU8::new(Unit::Count.to_u8()),
|
||||
detail: Mutex::new(String::new()),
|
||||
cancellable: AtomicBool::new(cancel.is_some()),
|
||||
cancel,
|
||||
gate: Mutex::new(()),
|
||||
});
|
||||
lock(&self.inner.tasks).push(entry.clone());
|
||||
TaskGuard { activity: self.clone(), entry }
|
||||
}
|
||||
|
||||
/// Every task in flight, oldest first.
|
||||
pub fn list(&self) -> Vec<TaskInfo> {
|
||||
lock(&self.inner.tasks).iter().map(|e| e.info()).collect()
|
||||
}
|
||||
|
||||
/// Ask task `id` to stop (it stops at its next check). An unknown id (the task may have just ended) or a task
|
||||
/// that can't be cancelled is an error.
|
||||
pub fn cancel(&self, id: u64) -> Result<(), String> {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?;
|
||||
if entry.try_cancel() { Ok(()) } else { Err(format!("{} can't be cancelled", entry.label)) }
|
||||
}
|
||||
|
||||
/// Ask every cancellable task to stop; returns how many were asked.
|
||||
pub fn cancel_all(&self) -> usize {
|
||||
let tasks = lock(&self.inner.tasks);
|
||||
tasks.iter().filter(|e| e.try_cancel()).count()
|
||||
}
|
||||
|
||||
/// Tasks that quitting would cut short: cancellable ones not already stopping.
|
||||
pub fn running_cancellable(&self) -> Vec<TaskInfo> {
|
||||
lock(&self.inner.tasks).iter().filter(|e| e.is_cancellable() && !e.is_cancelled()).map(|e| e.info()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A task's place in the list, held by whoever owns the job; dropping it removes the row.
|
||||
pub struct TaskGuard {
|
||||
activity: Activity,
|
||||
entry: Arc<Entry>,
|
||||
}
|
||||
|
||||
impl TaskGuard {
|
||||
pub fn id(&self) -> u64 {
|
||||
self.entry.id
|
||||
}
|
||||
|
||||
/// `done` of `total` (`total` 0 = not known yet). `done` is kept at most `total`.
|
||||
pub fn progress(&self, done: u64, total: u64) {
|
||||
self.entry.progress(done, total);
|
||||
}
|
||||
|
||||
/// The file, photo or model being worked on (a name, not a sentence).
|
||||
pub fn detail(&self, text: &str) {
|
||||
self.entry.detail(text);
|
||||
}
|
||||
|
||||
pub fn set_unit(&self, unit: Unit) {
|
||||
self.entry.unit.store(unit.to_u8(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.set_cancellable(yes);
|
||||
}
|
||||
|
||||
pub fn cancel_flag(&self) -> Option<Arc<AtomicBool>> {
|
||||
self.entry.cancel.clone()
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.entry.is_cancelled()
|
||||
}
|
||||
|
||||
/// For progress from another thread; it does not keep the row.
|
||||
pub fn handle(&self) -> TaskHandle {
|
||||
TaskHandle { entry: self.entry.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TaskGuard {
|
||||
fn drop(&mut self) {
|
||||
let id = self.entry.id;
|
||||
lock(&self.activity.inner.tasks).retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TaskGuard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TaskGuard").field("id", &self.entry.id).field("kind", &self.entry.kind).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress for a task from another thread (see [`TaskGuard::handle`]).
|
||||
#[derive(Clone)]
|
||||
pub struct TaskHandle {
|
||||
entry: Arc<Entry>,
|
||||
}
|
||||
|
||||
impl TaskHandle {
|
||||
pub fn progress(&self, done: u64, total: u64) {
|
||||
self.entry.progress(done, total);
|
||||
}
|
||||
|
||||
pub fn detail(&self, text: &str) {
|
||||
self.entry.detail(text);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.entry.is_cancelled()
|
||||
}
|
||||
|
||||
/// [`TaskGuard::set_cancellable`] from the worker, at the moment its step starts (an export
|
||||
/// handing its files to Apple Photos, which can't be stopped once asked). Read the cancel flag
|
||||
/// after turning it off: a cancel accepted before is seen then, any later one is refused.
|
||||
pub fn set_cancellable(&self, yes: bool) {
|
||||
self.entry.set_cancellable(yes);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[test]
|
||||
fn guard_drop_removes_the_row() {
|
||||
let a = Activity::default();
|
||||
let g = a.start("export", "Exporting", Cancel::Yes);
|
||||
assert_eq!(a.list().len(), 1);
|
||||
drop(g);
|
||||
assert!(a.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_drop_on_panic_unwind_removes_the_row() {
|
||||
let a = Activity::default();
|
||||
let a2 = a.clone();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||
let _g = a2.start("import", "Importing", Cancel::Yes);
|
||||
panic!("synthetic worker panic");
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
assert!(a.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_sets_the_adopted_flag() {
|
||||
let a = Activity::default();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
|
||||
a.cancel(g.id()).unwrap();
|
||||
assert!(flag.load(Ordering::Relaxed) && g.is_cancelled());
|
||||
assert!(a.list()[0].cancelling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_errors_instead_of_panicking() {
|
||||
let a = Activity::default();
|
||||
assert!(a.cancel(42).is_err());
|
||||
let g = a.start("faces", "Finding faces", Cancel::No);
|
||||
assert!(a.cancel(g.id()).is_err());
|
||||
let h = a.start("lightroom", "Importing Lightroom catalog", Cancel::Yes);
|
||||
h.set_cancellable(false);
|
||||
assert!(a.cancel(h.id()).is_err());
|
||||
assert!(!h.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_counts_only_cancellable() {
|
||||
let a = Activity::default();
|
||||
let (_x, _y, _z) = (a.start("export", "E", Cancel::Yes), a.start("import", "I", Cancel::Yes), a.start("faces", "F", Cancel::No));
|
||||
assert_eq!(a.cancel_all(), 2);
|
||||
assert!(a.running_cancellable().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_is_clamped_and_detail_capped() {
|
||||
let a = Activity::default();
|
||||
let g = a.start("export", "E", Cancel::Yes);
|
||||
g.progress(12, 10);
|
||||
g.detail(&"é".repeat(300));
|
||||
let t = &a.list()[0];
|
||||
assert_eq!((t.done, t.total), (10, 10));
|
||||
assert_eq!(t.detail.chars().count(), 200);
|
||||
g.progress(5, 0); // indeterminate keeps done as given
|
||||
assert_eq!(a.list()[0].total, 0);
|
||||
assert_eq!(a.list()[0].unit, Unit::Count);
|
||||
g.set_unit(Unit::Bytes);
|
||||
assert_eq!(a.list()[0].unit, Unit::Bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_list_is_oldest_first() {
|
||||
let a = Activity::default();
|
||||
let (g1, g2) = (a.start("export", "A", Cancel::Yes), a.start("import", "B", Cancel::Yes));
|
||||
assert!(g2.id() > g1.id());
|
||||
assert_eq!(a.list().iter().map(|t| t.id).collect::<Vec<_>>(), vec![g1.id(), g2.id()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_lock_still_works() {
|
||||
let a = Activity::default();
|
||||
let a2 = a.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _held = a2.inner.tasks.lock().unwrap();
|
||||
panic!("synthetic panic while holding the task list");
|
||||
})
|
||||
.join();
|
||||
assert!(a.inner.tasks.is_poisoned());
|
||||
let _g = a.start("export", "E", Cancel::Yes);
|
||||
assert_eq!(a.list().len(), 1);
|
||||
}
|
||||
|
||||
/// Codex review of #643: a worker that turns cancelling off and then reads the cancel flag
|
||||
/// (an export handing its files to Apple Photos) must either see a cancel that was accepted or
|
||||
/// make it fail, never let one be accepted and then ignored. Here the test holds the task's gate
|
||||
/// as a cancel does while it decides: the worker's switch waits for it, and then sees the cancel.
|
||||
#[test]
|
||||
fn a_step_that_turns_cancelling_off_sees_or_refuses_every_cancel() {
|
||||
let a = Activity::default();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let g = a.start("export", "Exporting", Cancel::Flag(flag.clone()));
|
||||
let handle = g.handle();
|
||||
let gate = lock(&g.entry.gate); // a cancel deciding
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (h, f) = (handle.clone(), flag.clone());
|
||||
let worker = std::thread::spawn(move || {
|
||||
h.set_cancellable(false);
|
||||
tx.send(f.load(Ordering::Relaxed)).unwrap();
|
||||
});
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "the switch waits for the cancel");
|
||||
flag.store(true, Ordering::Relaxed); // the cancel goes through...
|
||||
drop(gate);
|
||||
assert!(rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(), "...and the step sees it");
|
||||
worker.join().unwrap();
|
||||
// once it is off, cancels are refused
|
||||
let g2 = a.start("export", "Exporting", Cancel::Yes);
|
||||
g2.handle().set_cancellable(false);
|
||||
assert!(a.cancel(g2.id()).is_err());
|
||||
assert_eq!(a.cancel_all(), 0, "{:?}", a.list());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_updates_from_another_thread() {
|
||||
let a = Activity::default();
|
||||
let g = a.start("previews", "Building previews", Cancel::Yes);
|
||||
let h = g.handle();
|
||||
std::thread::spawn(move || h.progress(3, 7)).join().unwrap();
|
||||
assert_eq!((a.list()[0].done, a.list()[0].total), (3, 7));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1862
-59
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
|
||||
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
|
||||
/// `(model, JSON)`.
|
||||
pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "activity.list",
|
||||
"Background Tasks",
|
||||
[],
|
||||
None,
|
||||
"{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet",
|
||||
always,
|
||||
|s, _| Ok(json!({"tasks": s.activity.list()}))
|
||||
),
|
||||
cmd!(
|
||||
"activity.cancel",
|
||||
"Cancel Background Task",
|
||||
[],
|
||||
None,
|
||||
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "activity.cancel";
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(json!({"cancelled": s.activity.cancel_all()}));
|
||||
}
|
||||
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;
|
||||
s.activity.cancel(id).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"cancelled": 1}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::activity::Cancel;
|
||||
|
||||
#[test]
|
||||
fn list_and_cancel_through_execute() {
|
||||
let mut s = Session::new();
|
||||
let g = s.activity.start("export", "Exporting", Cancel::Yes);
|
||||
let r = s.execute("activity.list", &json!({})).unwrap();
|
||||
assert_eq!(r["tasks"][0]["kind"], "export");
|
||||
assert_eq!(r["tasks"][0]["cancellable"], true);
|
||||
let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap();
|
||||
assert_eq!(r["cancelled"], 1);
|
||||
assert!(g.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_and_errors() {
|
||||
let mut s = Session::new();
|
||||
let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No));
|
||||
assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1);
|
||||
assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err());
|
||||
assert!(s.execute("activity.cancel", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
// looking at a folder never brings its deleted photos back (importing the files does)
|
||||
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
|
||||
let report = import_with(s, &files, &opts)?;
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
@@ -105,7 +107,7 @@ impl Session {
|
||||
let smart = self.media.smart_dir.clone();
|
||||
let plan = self
|
||||
.catalog
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()));
|
||||
if dry_run {
|
||||
return plan;
|
||||
}
|
||||
@@ -195,6 +197,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.create",
|
||||
"New Folder on Disk",
|
||||
[],
|
||||
None,
|
||||
"{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
|
||||
always,
|
||||
create_folder
|
||||
),
|
||||
cmd!(
|
||||
"photo.moveToFolder",
|
||||
"Move to Folder",
|
||||
[],
|
||||
None,
|
||||
"{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}",
|
||||
always,
|
||||
move_to_folder
|
||||
),
|
||||
cmd!(
|
||||
"library.browse",
|
||||
"Browse Folder",
|
||||
@@ -249,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the
|
||||
/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites.
|
||||
pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
match (from.is_empty(), to.is_empty()) {
|
||||
(false, false) => rename_folder_on_disk(from, to),
|
||||
(false, true) => remove_empty_folder(from),
|
||||
(true, false) => {
|
||||
if Path::new(to).exists() {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
std::fs::create_dir(to).map_err(|e| format!("{to}: {e}"))
|
||||
}
|
||||
(true, true) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an empty folder; one with anything in it (hidden files too) is refused.
|
||||
fn remove_empty_folder(path: &str) -> std::result::Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
if !p.is_dir() {
|
||||
return Err(format!("{path} is not a folder"));
|
||||
}
|
||||
let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(format!("{path} is not empty"));
|
||||
}
|
||||
std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A folder path parameter without trailing separators (`None` when missing or blank).
|
||||
fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
|
||||
fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.create";
|
||||
let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
|
||||
.ok_or_else(|| bad(C, "give a folder name (no slashes)"))?;
|
||||
if !Path::new(parent).is_dir() {
|
||||
return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
|
||||
}
|
||||
let path = Path::new(parent).join(name).to_string_lossy().to_string();
|
||||
if Path::new(&path).exists() {
|
||||
return Err(bad(C, format!("{path} already exists: choose another name")));
|
||||
}
|
||||
folder_on_disk("", &path).map_err(|e| bad(C, e))?;
|
||||
let undo = crate::FolderMove { from: path.clone(), to: String::new() };
|
||||
if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
|
||||
let _ = remove_empty_folder(&path);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink
|
||||
/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name
|
||||
/// already taken in `folder` refuses the whole move.
|
||||
fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.moveToFolder";
|
||||
let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
|
||||
let dir = Path::new(folder);
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no photos to move: select some or give ids"));
|
||||
}
|
||||
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
|
||||
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut clashes: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
if ph.local {
|
||||
return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name)));
|
||||
}
|
||||
let lightcraft_catalog::Source::File { path } = &ph.source else {
|
||||
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
|
||||
};
|
||||
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
|
||||
continue; // a virtual copy of a photo already moving
|
||||
}
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)")));
|
||||
}
|
||||
let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) };
|
||||
if src.parent() == Some(dir) {
|
||||
continue; // already there
|
||||
}
|
||||
let to = dir.join(file);
|
||||
let to_s = to.to_string_lossy().to_string();
|
||||
if to.exists() || !taken.insert(to_s.to_lowercase()) {
|
||||
clashes.push(file.to_string_lossy().to_string());
|
||||
continue;
|
||||
}
|
||||
let name = ph.file_name.clone();
|
||||
plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) });
|
||||
}
|
||||
if !clashes.is_empty() {
|
||||
let s_ = if clashes.len() == 1 { "" } else { "s" };
|
||||
return Err(bad(
|
||||
C,
|
||||
format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")),
|
||||
));
|
||||
}
|
||||
let n = plans.len();
|
||||
if n == 0 {
|
||||
return Ok(json!({"moved": 0, "folder": folder}));
|
||||
}
|
||||
let plural = |k: usize| if k == 1 { "" } else { "s" };
|
||||
let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
|
||||
let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
|
||||
s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
|
||||
Ok(json!({"moved": n, "folder": folder}))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
// a library folder chosen at or below it follows too, as a source or as a filter
|
||||
@@ -285,8 +426,11 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
// Repairs retain their exact old paths, including those on surviving history entries.
|
||||
// Complete them before the directory moves; an unavailable repair leaves it in place.
|
||||
s.repair_sidecars_with(&crate::rename::RealFs).map_err(|e| e.to_string())?;
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
let mut ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
@@ -301,6 +445,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
})
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
// what the library knows about the folder (and those inside it) goes along, in the same step
|
||||
ops.extend(s.catalog.folder_records_follow(from, to));
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
|
||||
@@ -120,33 +120,67 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
|
||||
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
|
||||
/// stack it on top of the original. The host opens the file in the editor.
|
||||
/// TIFF (or PSD, or 8-bit) next to the original (`<name>-Edit.tif`, never overwriting), add it to
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let mut ids = s.targets(p);
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no active photo"));
|
||||
}
|
||||
if ids.len() > MAX_EDIT_COPIES {
|
||||
return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len())));
|
||||
}
|
||||
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
|
||||
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
|
||||
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
|
||||
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
|
||||
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
|
||||
};
|
||||
let format = match super::str_param(p, "format").unwrap_or("tiff") {
|
||||
"tiff" | "tif" => crate::export::ExportFormat::Tiff,
|
||||
"psd" => crate::export::ExportFormat::Psd,
|
||||
f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))),
|
||||
};
|
||||
let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) {
|
||||
8 => 8,
|
||||
16 => 16,
|
||||
d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))),
|
||||
};
|
||||
let opts = crate::export::ExportOptions {
|
||||
format: crate::export::ExportFormat::Tiff,
|
||||
bit_depth: Some(16),
|
||||
format,
|
||||
bit_depth: Some(bit_depth),
|
||||
color_space: space,
|
||||
naming: "{name}-Edit".into(),
|
||||
conflict: crate::export::Conflict::Unique,
|
||||
..Default::default()
|
||||
};
|
||||
let mut copies = Vec::new();
|
||||
for id in ids {
|
||||
copies.push(edit_copy(s, p, id, &opts)?);
|
||||
}
|
||||
let new: Vec<lightcraft_catalog::PhotoId> = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect();
|
||||
s.selection = crate::Selection { active: new.first().copied(), ids: new };
|
||||
let first = copies.first().cloned().unwrap_or(Value::Null);
|
||||
Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies}))
|
||||
}
|
||||
|
||||
/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo).
|
||||
const MAX_EDIT_COPIES: usize = 50;
|
||||
|
||||
/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo
|
||||
/// photo into `dir`), added to the library and stacked on top of the original, expanded.
|
||||
fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
let dir = match &ph.source {
|
||||
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
Source::Demo { .. } => match super::str_param(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
|
||||
},
|
||||
};
|
||||
let mut written = Vec::new();
|
||||
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
|
||||
// durable: the TIFF becomes a library photo the external editor then changes
|
||||
@@ -155,13 +189,13 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
};
|
||||
let to = crate::export::Destination { dir, exact: None };
|
||||
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let ext = format!(".{}", opts.format.extension());
|
||||
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
||||
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
||||
// stack: the edit on top of the original, expanded so both show
|
||||
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
|
||||
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
|
||||
Ok(json!({"path": out, "id": new, "original": id.0}))
|
||||
}
|
||||
|
||||
@@ -210,6 +244,33 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
ops
|
||||
}
|
||||
|
||||
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
|
||||
/// it already has them. A catalog from before the lens data was read at import has none, so such a
|
||||
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
|
||||
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
|
||||
/// its settings.
|
||||
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
||||
if info.embedded_lens == ph.embedded_lens {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
|
||||
// (a photo with a default-preset look keeps it: that look is its import state)
|
||||
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.optics.lens_profile = true;
|
||||
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the
|
||||
/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices
|
||||
/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730).
|
||||
pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
|
||||
let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?;
|
||||
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -229,21 +290,21 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let Some(op) = content_op(id, ph, info.clone()) else {
|
||||
if !meta_ops.is_empty() {
|
||||
ops.extend(meta_ops);
|
||||
reloaded.push(id);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ops.push(op);
|
||||
ops.extend(meta_ops);
|
||||
let mut mine: Vec<Op> =
|
||||
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
reloaded.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
reloaded.push(c.id);
|
||||
let theirs: Vec<Op> =
|
||||
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
}
|
||||
reloaded.push(id);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
@@ -316,7 +377,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
@@ -325,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Edit Copy for External Editor",
|
||||
[],
|
||||
None,
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
"{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)",
|
||||
has_active,
|
||||
edit_external
|
||||
),
|
||||
@@ -359,4 +420,103 @@ mod tests {
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
vignette: None,
|
||||
stages: Default::default(),
|
||||
}
|
||||
}
|
||||
fn old_rw2() -> lightcraft_catalog::Photo {
|
||||
// as an older build catalogued it: no lens data, correction off
|
||||
lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
Source::File { path: "/x.rw2".into() },
|
||||
"x.rw2",
|
||||
"RW2",
|
||||
4592,
|
||||
3448,
|
||||
"2026-10-06T00:00:00",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
|
||||
let ph = old_rw2();
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
|
||||
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
|
||||
assert!(settings.optics.lens_profile);
|
||||
// once applied, the photo is still "unedited" and has what import would have given it
|
||||
let mut c = lightcraft_catalog::Catalog::default();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
|
||||
for op in ops {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
|
||||
assert_eq!(after.embedded_lens, Some(lens()));
|
||||
assert!(!after.is_edited());
|
||||
assert_eq!(*after.develop, after.camera_defaults());
|
||||
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
|
||||
let mut ph = old_rw2();
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.light.exposure = 0.7;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
assert!(ph.is_edited());
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
|
||||
let mut ph = old_rw2();
|
||||
ph.embedded_lens = Some(lens());
|
||||
let mut d = ph.camera_defaults();
|
||||
d.optics.lens_profile = false;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
assert!(lens_ops(ph.id, &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
|
||||
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
|
||||
#[test]
|
||||
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
|
||||
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let ph = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
|
||||
// back to how 0.4.0 catalogued it
|
||||
let mut old = (*ph.develop).clone();
|
||||
old.optics.lens_profile = false;
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
|
||||
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
|
||||
s.media.forget(id);
|
||||
let before = s.render_now(id, 160, 120).unwrap().image.data;
|
||||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]));
|
||||
let now = s.catalog.photo(id).unwrap().clone();
|
||||
assert_eq!(now.embedded_lens, ph.embedded_lens);
|
||||
assert!(now.develop.optics.lens_profile && !now.is_edited());
|
||||
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let undone = s.catalog.photo(id).unwrap();
|
||||
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
|
||||
}
|
||||
}
|
||||
@@ -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()}))
|
||||
@@ -368,7 +379,7 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = lightcraft_denoise::known::host(&spec.url).to_string();
|
||||
s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
|
||||
s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
@@ -614,6 +625,7 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
|
||||
s.denoise_track();
|
||||
Ok(json!({
|
||||
"active": s.denoise.active.is_some(),
|
||||
"running": s.denoise_running_json(),
|
||||
@@ -652,7 +664,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
|
||||
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?, remote?: {address, state: pending | unavailable | used | idle | slower | given up, device?, remoteMs?, localMs?, reason?, failures?, lastError?}}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
|
||||
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
if o.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
|
||||
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
|
||||
/// canvas outside the photo (issue #742).
|
||||
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
|
||||
if touched {
|
||||
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
const CROP_ANGLE: &str = "crop.angle";
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
@@ -74,6 +85,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
|
||||
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
|
||||
fn outdated(s: &Session, id: PhotoId) -> bool {
|
||||
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
|
||||
}
|
||||
|
||||
fn already_current() -> String {
|
||||
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
|
||||
}
|
||||
|
||||
/// Update to Current Process applies to the selection (the selected photos, else the active
|
||||
/// photo): one of them is on an older rendering process. Calls that name their photos are
|
||||
/// checked by the command instead (`explicit_targets`).
|
||||
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let sel = &s.selection;
|
||||
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
|
||||
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
|
||||
}
|
||||
|
||||
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
|
||||
/// names none (the selection applies).
|
||||
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
|
||||
if !super::names_photos(p) {
|
||||
return Ok(None);
|
||||
}
|
||||
let given = match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
|
||||
None => p.get("id").cloned().into_iter().collect(),
|
||||
};
|
||||
if given.is_empty() {
|
||||
return Err(bad(c, "`ids` names no photos"));
|
||||
}
|
||||
let ids = given
|
||||
.iter()
|
||||
.map(|v| match v.as_u64().map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
_ => Err(bad(c, format!("no such photo {v}"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(Some(ids))
|
||||
}
|
||||
|
||||
/// The user preset named by `p.id` (built-ins can't be changed).
|
||||
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
|
||||
@@ -103,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Develop Values",
|
||||
[],
|
||||
None,
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mut vals: Vec<(String, f64)> = Vec::new();
|
||||
@@ -124,10 +178,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
|
||||
let before = d.crop.geometry;
|
||||
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
lightcraft_pipeline::local::normalize_wb(d, info);
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
}
|
||||
for (k, v) in &vals {
|
||||
controls::set(d, k, *v);
|
||||
@@ -135,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
}
|
||||
refit_crop_angle(angle, before, d, dims);
|
||||
};
|
||||
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
|
||||
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
@@ -144,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
apply(&mut d, &info, crop_dims(s, id));
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
@@ -153,8 +213,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
let dims = crop_dims(s, id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d, &info);
|
||||
apply(d, &info, dims);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -163,9 +224,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
let id = active(s, "develop.adjust")?;
|
||||
let (info, dims) = (s.source_info(id), crop_dims(s, id));
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
@@ -174,6 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -196,19 +261,62 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => {
|
||||
// the photo's built-in defaults (raws: capture sharpening and colour noise
|
||||
// reduction, embedded lens corrections), as at import, with the white balance
|
||||
// of its current as-shot reference
|
||||
let info = s.source_info(id);
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
}
|
||||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||||
let mut fresh = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance {
|
||||
mode: WbMode::AsShot,
|
||||
temp: info.as_shot_temp,
|
||||
tint: info.as_shot_tint,
|
||||
..Default::default()
|
||||
},
|
||||
..defaults
|
||||
};
|
||||
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
|
||||
fresh
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
|
||||
})
|
||||
.collect();
|
||||
s.commit("Reset", Op::Batch { ops })?;
|
||||
ok()
|
||||
}),
|
||||
CommandSpec {
|
||||
explicit_targets: true,
|
||||
..cmd!(
|
||||
"develop.updateProcess",
|
||||
"Update to Current Process",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
|
||||
has_outdated_process,
|
||||
|s, p| {
|
||||
const C: &str = "develop.updateProcess";
|
||||
const LABEL: &str = "Update to Current Process";
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter(|(_, d)| d.process.is_outdated())
|
||||
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(LABEL, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
|
||||
}
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"develop.resetSection",
|
||||
"Reset Section",
|
||||
@@ -227,8 +335,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
|
||||
let dims = crop_dims(s, active(s, "develop.resetControl")?);
|
||||
edit(s, "develop.resetControl", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
controls::set(d, &c, spec.default);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -302,7 +413,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
m => m
|
||||
.preset()
|
||||
.map(|(t, ti)| {
|
||||
// presets are relative to daylight for rendered files
|
||||
// presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
|
||||
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
})
|
||||
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||
@@ -312,6 +423,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
|
||||
// mode is kept for the UI's WB dropdown.
|
||||
edit(s, "develop.wb", "White Balance", |d| {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
d.wb.mode = mode;
|
||||
d.wb.temp = t.clamp(2000.0, 50000.0);
|
||||
d.wb.tint = tint.clamp(-150.0, 150.0);
|
||||
@@ -341,6 +453,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
|
||||
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
|
||||
edit(s, "develop.wbPick", "White Balance", |d| {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
d.wb.mode = WbMode::Custom;
|
||||
d.wb.temp = t;
|
||||
d.wb.tint = tint;
|
||||
@@ -414,10 +527,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
|
||||
}
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
|
||||
}
|
||||
d.crop.geometry = d.crop.geometry.constrained(w, h);
|
||||
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
|
||||
d.crop.geometry = match (p.get("rect"), angle) {
|
||||
// a new angle alone turns the crop as Straighten does (issue #742)
|
||||
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
|
||||
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -426,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Drag Crop Handle",
|
||||
[],
|
||||
None,
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.drag")?;
|
||||
@@ -451,11 +566,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
|
||||
};
|
||||
let keep_ratio = bool_or(p, "keepRatio", false);
|
||||
edit(s, "crop.drag", "Crop", |d| {
|
||||
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
|
||||
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
|
||||
// orientation is reinterpreted per image, so the frame itself is the truth.
|
||||
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
|
||||
// `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock.
|
||||
let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0);
|
||||
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
|
||||
Ok(())
|
||||
})
|
||||
@@ -473,11 +590,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
|
||||
edit(s, "crop.straighten", "Straighten", |d| {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
|
||||
// keep the current crop size if it still fits, else shrink
|
||||
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
|
||||
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
|
||||
// keep the current crop if it still fits, else the largest of its aspect that does
|
||||
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -572,6 +686,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
// ---- HDR editing
|
||||
cmd!(
|
||||
"develop.hdr",
|
||||
"HDR",
|
||||
[],
|
||||
None,
|
||||
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.hdr")?;
|
||||
let cur = s.develop_of(id).unwrap_or_default().hdr;
|
||||
let enabled = bool_or(p, "enabled", !cur.enabled);
|
||||
let max_ev = match p.get("maxEv") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
|
||||
};
|
||||
let label = if enabled { "HDR On" } else { "HDR Off" };
|
||||
edit(s, "develop.hdr", label, |d| {
|
||||
d.hdr.enabled = enabled;
|
||||
if let Some(m) = max_ev {
|
||||
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
|
||||
}
|
||||
),
|
||||
// ---- geometry: Upright
|
||||
cmd!(
|
||||
"geometry.upright",
|
||||
@@ -745,13 +886,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
|
||||
let info = s.source_info(id);
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
|
||||
if nd.wb.mode == WbMode::AsShot {
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
}
|
||||
let before = nd.crop.geometry;
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
nd.wb.mode = WbMode::Custom;
|
||||
}
|
||||
@@ -932,15 +1079,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| {
|
||||
@@ -957,3 +1136,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())
|
||||
}
|
||||
}
|
||||
@@ -78,8 +78,87 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
match &s.apple_photos {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(crate::apple_photos::unavailable().into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
|
||||
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
|
||||
/// worker thread (the default in the desktop app, which answers control requests on its UI
|
||||
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.addToPhotos";
|
||||
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
|
||||
}
|
||||
let paths: Vec<String> = match p.get("paths") {
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.map(|v| v.as_str().map(str::to_string))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
|
||||
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
|
||||
};
|
||||
let album = match p.get("album") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(a)) => a.as_str(),
|
||||
Some(_) => return Err(bad(ID, "`album` must be a string")),
|
||||
};
|
||||
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
|
||||
// in the middle of the import
|
||||
let wait = match p.get("wait") {
|
||||
None | Some(Value::Null) => !s.apple_photos_background,
|
||||
// the desktop app answers on its window's thread, which never waits for Photos
|
||||
Some(Value::Bool(true)) if s.apple_photos_background => {
|
||||
return Err(bad(
|
||||
ID,
|
||||
"`wait: true` isn't available in the desktop app: the import runs in the background; its outcome comes from export.photosImports {job}",
|
||||
));
|
||||
}
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
|
||||
};
|
||||
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait, Some(&s.activity)).map_err(|e| bad(ID, e))?;
|
||||
match job.outcome() {
|
||||
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
|
||||
_ => Ok(job.json()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.photosImports";
|
||||
match p.get("job") {
|
||||
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
|
||||
Some(v) => {
|
||||
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
|
||||
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
|
||||
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"export.presets",
|
||||
"Export Presets",
|
||||
@@ -112,5 +191,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
];
|
||||
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
|
||||
// replay must not import again.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.addToPhotos",
|
||||
"Add to Apple Photos",
|
||||
[],
|
||||
None,
|
||||
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app, where wait: true is refused (its control requests are answered on the UI thread, which never waits for Photos). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
|
||||
photos_enabled,
|
||||
add_to_photos
|
||||
));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.photosImports",
|
||||
"Apple Photos Imports",
|
||||
[],
|
||||
None,
|
||||
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
|
||||
always,
|
||||
photos_imports
|
||||
));
|
||||
v
|
||||
}
|
||||
@@ -356,7 +356,13 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = spec.host().to_string();
|
||||
s.face_downloads.start(spec, &dir).map_err(|e| bad(C, e))?;
|
||||
let name = known::all()
|
||||
.into_iter()
|
||||
.find(|m| m.id == spec.id)
|
||||
.map(|m| m.name)
|
||||
.or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == spec.id).map(|e| e.manifest.name.clone()))
|
||||
.unwrap_or_else(|| spec.id.clone());
|
||||
s.face_downloads.start(spec, &dir, &s.activity, &name).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
@@ -458,6 +464,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.embedder.as_deref() == Some(id) {
|
||||
// another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen
|
||||
st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id);
|
||||
// on with nothing to run is not a state (issue #678)
|
||||
st.enabled &= st.embedder.is_some();
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
Ok(json!({"removed": id, "embedder": st.embedder}))
|
||||
@@ -485,7 +493,15 @@ fn enable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.enable";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st = read_settings(&dir);
|
||||
st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
let on = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
// on means something only with a model to run (issue #678): a toggle that is on with nothing
|
||||
// chosen would sit there doing nothing, with no word about why
|
||||
let usable =
|
||||
st.embedder.as_deref().is_some_and(|id| installed_models(&dir).iter().any(|i| i.manifest.id == id && i.manifest.role == Role::Embedder));
|
||||
if on && !usable {
|
||||
return Err(bad(C, "choose a recognition model first (Settings ▸ Faces ▸ Use)"));
|
||||
}
|
||||
st.enabled = on;
|
||||
write_settings(&dir, &st)?;
|
||||
// the background scan notices at once, not within the second it trusts its last look
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -515,6 +515,21 @@ mod imp {
|
||||
}))
|
||||
}
|
||||
|
||||
/// The scan's row in the activity stack: there while `pending` photos are left, its whole the most that were left at
|
||||
/// once since the scan last finished (which it returns; 0 when none are left). It can't be stopped from there: the
|
||||
/// scan is switched off in Settings.
|
||||
fn track(s: &mut Session, pending: u64) -> u64 {
|
||||
if pending == 0 {
|
||||
s.faces.task = None;
|
||||
s.faces.peak = 0;
|
||||
return 0;
|
||||
}
|
||||
s.faces.peak = s.faces.peak.max(pending);
|
||||
let task = s.faces.task.get_or_insert_with(|| s.activity.start("faces", "Finding faces", crate::activity::Cancel::No));
|
||||
task.progress(s.faces.peak.saturating_sub(pending), s.faces.peak);
|
||||
s.faces.peak
|
||||
}
|
||||
|
||||
/// `faces.pump`: cheap to call every frame. Takes in what the background worker has finished and, when it is idle,
|
||||
/// hands it the next photo with faces to embed. Does nothing (and says so) until recognition is switched on in
|
||||
/// Settings with a model chosen.
|
||||
@@ -528,14 +543,17 @@ mod imp {
|
||||
finish_downloads(s);
|
||||
let enabled = s.face_models_dir.clone().is_some_and(|d| enabled_cached(s, &d));
|
||||
if !enabled {
|
||||
track(s, 0);
|
||||
return Ok(json!({"active": false}));
|
||||
}
|
||||
if s.faces.retry_at.is_some_and(|t| Instant::now() < t) {
|
||||
track(s, 0);
|
||||
return Ok(json!({"active": false}));
|
||||
}
|
||||
let Ok(embedder) = current_with(s, C, false) else {
|
||||
// the chosen model is gone or broken: look again in a few seconds, not every frame
|
||||
s.faces.retry_at = Some(Instant::now() + Duration::from_secs(3));
|
||||
track(s, 0);
|
||||
return Ok(json!({"active": false}));
|
||||
};
|
||||
s.faces.retry_at = None;
|
||||
@@ -573,10 +591,13 @@ mod imp {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let pending = s.faces.queue.len() + s.faces.in_flight.len();
|
||||
let peak = track(s, pending as u64);
|
||||
Ok(json!({
|
||||
"active": true,
|
||||
"inFlight": s.faces.in_flight.len(),
|
||||
"pendingPhotos": s.faces.queue.len() + s.faces.in_flight.len(),
|
||||
"pendingPhotos": pending,
|
||||
"peak": peak,
|
||||
"indexedFaces": s.faces.index.len(),
|
||||
"searchedPhotos": s.faces.scanned.len(),
|
||||
"workers": target,
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the
|
||||
//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user
|
||||
//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder;
|
||||
//! after that every lookup is local and no position ever leaves the machine.
|
||||
//!
|
||||
//! Sources: GeoNames dump readme (prose); own design.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param};
|
||||
use crate::activity::Cancel;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Where the place names live: `dataset` (a folder) or `<settings>/geonames`.
|
||||
fn dataset_dir(p: &Value) -> Option<PathBuf> {
|
||||
str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames")))
|
||||
}
|
||||
|
||||
/// How a place-names download ended.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DownloadOutcome {
|
||||
/// Done: this many places.
|
||||
Done(usize),
|
||||
/// Failed, in words the user can act on.
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DownloadShared {
|
||||
running: bool,
|
||||
last: Option<DownloadOutcome>,
|
||||
/// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]).
|
||||
unreported: Option<DownloadOutcome>,
|
||||
}
|
||||
|
||||
/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state.
|
||||
#[derive(Default)]
|
||||
pub struct PlaceNames {
|
||||
shared: Arc<Mutex<DownloadShared>>,
|
||||
/// (cities file, its size, its modification time, the parsed names).
|
||||
cache: Option<(PathBuf, u64, Option<SystemTime>, Arc<GeoNames>)>,
|
||||
}
|
||||
|
||||
impl PlaceNames {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> {
|
||||
self.shared.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A download that ended since the last call (the UI shows it once: an error stays readable as a toast).
|
||||
pub fn take_outcome(&self) -> Option<DownloadOutcome> {
|
||||
self.lock().unreported.take()
|
||||
}
|
||||
|
||||
pub fn downloading(&self) -> bool {
|
||||
self.lock().running
|
||||
}
|
||||
|
||||
/// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes.
|
||||
fn load(&mut self, dir: &Path) -> std::result::Result<Arc<GeoNames>, lightcraft_geocode::GeocodeError> {
|
||||
let file = dir.join(GEONAMES_FILES[0]);
|
||||
let meta = std::fs::metadata(&file).ok();
|
||||
let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok()));
|
||||
if let Some((f, l, t, g)) = &self.cache
|
||||
&& meta.is_some()
|
||||
&& *f == file
|
||||
&& *l == len
|
||||
&& *t == mtime
|
||||
{
|
||||
return Ok(g.clone());
|
||||
}
|
||||
let g = Arc::new(GeoNames::load(dir)?);
|
||||
self.cache = Some((file, len, mtime, g.clone()));
|
||||
Ok(g)
|
||||
}
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = dataset_dir(p);
|
||||
let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len());
|
||||
let sh = s.place_names.lock();
|
||||
let (last, error) = match &sh.last {
|
||||
None => (Value::Null, Value::Null),
|
||||
Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null),
|
||||
Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)),
|
||||
Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null),
|
||||
};
|
||||
Ok(json!({
|
||||
"downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error,
|
||||
"attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`.
|
||||
fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) {
|
||||
for (field, value) in [
|
||||
(&mut meta.location, &place.sublocation),
|
||||
(&mut meta.city, &place.city),
|
||||
(&mut meta.state, &place.state),
|
||||
(&mut meta.country, &place.country),
|
||||
] {
|
||||
if !value.is_empty() && (overwrite || field.trim().is_empty()) {
|
||||
*field = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
let ids = s.targets(p);
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (la, lo) = s.catalog.photo(*id)?.meta.gps?;
|
||||
let at = LatLon::new(la, lo);
|
||||
at.is_valid().then_some((*id, at))
|
||||
})
|
||||
.collect();
|
||||
if todo.is_empty() {
|
||||
return Err(bad(C, "none of these photos has a GPS position"));
|
||||
}
|
||||
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
|
||||
let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?;
|
||||
// a large selection shows in the activity stack and can be stopped
|
||||
let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes));
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
for (n, (id, at)) in todo.iter().enumerate() {
|
||||
if let Some(t) = &task {
|
||||
if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {
|
||||
return Err(bad(C, "cancelled"));
|
||||
}
|
||||
t.progress(n as u64, todo.len() as u64);
|
||||
}
|
||||
let Some(place) = names.reverse(*at) else { continue };
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let mut meta = ph.meta.clone();
|
||||
fill(&mut meta, &place, overwrite);
|
||||
if meta != ph.meta {
|
||||
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
|
||||
}
|
||||
found.push(json!({"id": id.0, "place": place}));
|
||||
}
|
||||
drop(task);
|
||||
let changed = ops.len();
|
||||
if !bool_or(p, "dryRun", false) && changed > 0 {
|
||||
s.commit("Reverse Geocode", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod download {
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
|
||||
|
||||
use super::*;
|
||||
use crate::activity::Unit;
|
||||
|
||||
const MAX_ZIP: u64 = 64 * 1024 * 1024;
|
||||
const MAX_TEXT: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> {
|
||||
let _ = std::fs::remove_file(dir.join(name));
|
||||
let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP };
|
||||
lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| {
|
||||
task.progress(pr.done, pr.total.max(pr.done))
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete.
|
||||
pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<usize, String> {
|
||||
let staging = dir.join(".download");
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?;
|
||||
get(GEONAMES_ZIP, base, &staging, cancel, task)?;
|
||||
let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?;
|
||||
let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?;
|
||||
let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len();
|
||||
if places == 0 {
|
||||
return Err("the downloaded place list is empty".into());
|
||||
}
|
||||
let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP));
|
||||
let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}"));
|
||||
for name in &GEONAMES_FILES[1..] {
|
||||
// optional: without them states stay blank and countries are ISO codes
|
||||
match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) {
|
||||
Ok(b) => write(name, &b)?,
|
||||
Err(e) => log::warn!("place names: {name}: {e}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(staging.join(name));
|
||||
}
|
||||
write(GEONAMES_FILES[0], &cities)?;
|
||||
let _ = std::fs::remove_dir(&staging);
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// A download base: https, or plain http to this machine's loopback address only (a test
|
||||
/// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/`
|
||||
/// is refused.
|
||||
pub(super) fn base_allowed(base: &str) -> bool {
|
||||
base.starts_with("https://")
|
||||
|| base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable).
|
||||
pub(super) fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.downloadPlaceNames";
|
||||
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
|
||||
let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string();
|
||||
if !base_allowed(&base) {
|
||||
return Err(bad(C, "`base` must be an https:// address"));
|
||||
}
|
||||
let shared = s.place_names.shared.clone();
|
||||
{
|
||||
let mut sh = s.place_names.lock();
|
||||
if sh.running {
|
||||
return Err(bad(C, "the place names are already downloading"));
|
||||
}
|
||||
sh.running = true;
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone()));
|
||||
task.set_unit(Unit::Bytes);
|
||||
task.detail("GeoNames");
|
||||
let handle = task.handle();
|
||||
let thread_dir = dir.clone();
|
||||
let thread_shared = shared.clone();
|
||||
let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {
|
||||
// the activity row goes when the thread ends, however it ends
|
||||
let _task = task;
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));
|
||||
let outcome = match outcome {
|
||||
Ok(Ok(n)) => DownloadOutcome::Done(n),
|
||||
_ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,
|
||||
Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")),
|
||||
Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()),
|
||||
};
|
||||
if let DownloadOutcome::Failed(e) = &outcome {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
sh.running = false;
|
||||
sh.last = Some(outcome.clone());
|
||||
sh.unreported = Some(outcome);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;
|
||||
return Err(bad(C, format!("could not start the download: {e}")));
|
||||
}
|
||||
Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(
|
||||
"metadata.reverseGeocode",
|
||||
"Fill Location from GPS",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
|
||||
has_selection,
|
||||
reverse
|
||||
),
|
||||
cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status),
|
||||
];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
"metadata.downloadPlaceNames",
|
||||
"Download Place Names (GeoNames)",
|
||||
["Photo"],
|
||||
None,
|
||||
"{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent",
|
||||
always,
|
||||
download::start
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\
|
||||
2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n";
|
||||
|
||||
fn dataset(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) {
|
||||
let mut meta = s.catalog.photo(id).unwrap().meta.clone();
|
||||
meta.gps = gps;
|
||||
meta.city = city.into();
|
||||
meta.state.clear();
|
||||
meta.country.clear();
|
||||
meta.location.clear();
|
||||
s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_empty_fields_keeps_typed_ones_and_undoes() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let dir = dataset("fill");
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster");
|
||||
set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), "");
|
||||
set_gps(&mut s, ids[3], Some((0.0, 0.0)), "");
|
||||
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap();
|
||||
assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}");
|
||||
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France"));
|
||||
let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone();
|
||||
assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom"));
|
||||
assert!(s.activity.list().is_empty(), "the activity row ends with the command");
|
||||
// one undo step
|
||||
s.undo_step().unwrap();
|
||||
assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty());
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster");
|
||||
// overwrite replaces typed text
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap();
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_missing_data_and_gps() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
set_gps(&mut s, ids[0], None, "");
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err();
|
||||
assert!(e.to_string().contains("GPS"), "{e}");
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id()));
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err();
|
||||
assert!(e.to_string().contains("download"), "{e}");
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap();
|
||||
assert_eq!(st["downloaded"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gazetteer_is_cached_until_its_file_changes() {
|
||||
let dir = dataset("cache");
|
||||
let mut names = PlaceNames::default();
|
||||
let a = names.load(&dir).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read");
|
||||
// a different size invalidates it (a new download)
|
||||
std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap();
|
||||
let b = names.load(&dir).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome");
|
||||
std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap();
|
||||
assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip
|
||||
/// that isn't one), `short` (the body ends before its Content-Length), anything else 404.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn serve() -> String {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes());
|
||||
std::thread::spawn(move || {
|
||||
for conn in l.incoming() {
|
||||
let Ok(mut c) = conn else { continue };
|
||||
let mut r = BufReader::new(c.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
let _ = r.read_line(&mut line);
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let path = line.split(' ').nth(1).unwrap_or("").to_string();
|
||||
let (status, body, len): (&str, Vec<u8>, Option<usize>) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) {
|
||||
("good", true) => ("200 OK", zip.clone(), None),
|
||||
("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None),
|
||||
("garbage", true) => ("200 OK", b"<html>not a zip</html>".to_vec(), None),
|
||||
("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())),
|
||||
_ => ("404 Not Found", b"nope".to_vec(), None),
|
||||
};
|
||||
let n = len.unwrap_or(body.len());
|
||||
let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes());
|
||||
let _ = c.write_all(&body);
|
||||
}
|
||||
});
|
||||
format!("http://127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome {
|
||||
s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap();
|
||||
assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some());
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(o) = s.place_names.take_outcome() {
|
||||
// the activity row ends with the thread
|
||||
while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(s.activity.list().is_empty());
|
||||
return o;
|
||||
}
|
||||
assert!(started.elapsed().as_secs() < 60, "the download never ended");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn download_end_to_end_and_its_failures() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let base = serve();
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err());
|
||||
|
||||
for bad in ["missing", "garbage", "short"] {
|
||||
let o = download(&mut s, &format!("{base}/{bad}/"), &dir);
|
||||
let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") };
|
||||
assert!(e.contains("try again"), "{bad}: {e}");
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
|
||||
assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}");
|
||||
assert_eq!(st["lastError"].as_str(), Some(e.as_str()));
|
||||
assert!(s.place_names.take_outcome().is_none(), "reported once");
|
||||
}
|
||||
|
||||
assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2));
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
|
||||
assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2)));
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
|
||||
let m = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
// admin1 was a 404 (optional): the state stays blank, the rest is filled
|
||||
assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_base_is_https_or_loopback_only() {
|
||||
assert!(download::base_allowed("https://download.geonames.org/export/dump/"));
|
||||
assert!(download::base_allowed("http://127.0.0.1:8080"));
|
||||
assert!(download::base_allowed("http://127.0.0.1/dump"));
|
||||
assert!(!download::base_allowed("http://127.0.0.1.example.com/"));
|
||||
assert!(!download::base_allowed("http://127.0.0.1@example.com/"));
|
||||
assert!(!download::base_allowed("http://download.geonames.org/"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
||||
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
@@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
|
||||
/// keyword and is cleared when its keyword is deleted.
|
||||
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
|
||||
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
|
||||
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
|
||||
let n = match &op {
|
||||
lightcraft_catalog::Op::Batch { ops } => ops.len(),
|
||||
_ => 1,
|
||||
use lightcraft_catalog::Op;
|
||||
let (photos, listed) = match &op {
|
||||
Op::Batch { ops } => {
|
||||
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
|
||||
}
|
||||
Op::SetMeta { .. } => (1, 0),
|
||||
_ => (0, 1),
|
||||
};
|
||||
if n > 0 {
|
||||
if photos + listed > 0 {
|
||||
s.commit(label, op)?;
|
||||
}
|
||||
if let Some(k) = s.filter.keyword.clone() {
|
||||
s.filter.keyword = follow(&k);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
Ok(json!({"changed": photos, "listed": listed}))
|
||||
}
|
||||
|
||||
/// A keyword's attributes from command params over `base`: those not given keep their value.
|
||||
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
|
||||
KeywordInfo {
|
||||
synonyms: if p.get("synonyms").is_some() {
|
||||
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
} else {
|
||||
base.synonyms
|
||||
},
|
||||
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
|
||||
export_containing: bool_or(p, "exportContaining", base.export_containing),
|
||||
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
|
||||
person: bool_or(p, "person", base.person),
|
||||
new_keywords_inside: base.new_keywords_inside,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyword a command names (`keyword`), as the library writes it.
|
||||
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
|
||||
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
|
||||
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
|
||||
}
|
||||
|
||||
/// A catalog refusal as a command error; a taken name says how to merge.
|
||||
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
|
||||
match e {
|
||||
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
|
||||
e => bad(command, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of up to nine keywords.
|
||||
@@ -38,6 +72,12 @@ pub struct KeywordSet {
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
|
||||
/// keywords are.
|
||||
fn same_set(a: &str, b: &str) -> bool {
|
||||
lightcraft_catalog::keywords::same(a.trim(), b.trim())
|
||||
}
|
||||
|
||||
/// The set name meaning "the nine most recently added keywords".
|
||||
pub const RECENT: &str = "Recent Keywords";
|
||||
|
||||
@@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
s.recent_keywords.retain(|x| !same_set(x, &k));
|
||||
s.recent_keywords.insert(0, k);
|
||||
}
|
||||
s.recent_keywords.truncate(9);
|
||||
let _ = s.save_prefs();
|
||||
}
|
||||
|
||||
/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB).
|
||||
const MAX_LIST_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here).
|
||||
const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>'];
|
||||
|
||||
/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark).
|
||||
fn read_list(path: &str) -> Result<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
if bytes.len() as u64 > MAX_LIST_BYTES {
|
||||
return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
|
||||
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
|
||||
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text")))
|
||||
}
|
||||
|
||||
/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others
|
||||
/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty),
|
||||
/// nine at most, no empty ones at the end.
|
||||
pub fn slots(typed: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for k in typed.iter().take(9) {
|
||||
let k = clean(k);
|
||||
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
|
||||
out.push(if twice { String::new() } else { k });
|
||||
}
|
||||
while out.last().is_some_and(String::is_empty) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
||||
pub fn current_keywords(s: &Session) -> Vec<String> {
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
|
||||
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
||||
v.truncate(9);
|
||||
v
|
||||
@@ -75,13 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
||||
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
||||
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
|
||||
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.find(|x| same_set(&x.name, name))
|
||||
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
||||
.name
|
||||
.clone(),
|
||||
@@ -95,24 +173,53 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Keyword Set",
|
||||
[],
|
||||
None,
|
||||
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
|
||||
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
|
||||
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
|
||||
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
||||
.to_string();
|
||||
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
|
||||
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
|
||||
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
|
||||
// the set it renames, if any (an empty `replace` is none)
|
||||
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
|
||||
Some(old) => Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.position(|x| same_name(x, old))
|
||||
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// keywords not given: the renamed set's own, else the current nine
|
||||
let keywords = if p.get("keywords").is_some() {
|
||||
slots(&strs(p, "keywords"))
|
||||
} else {
|
||||
current_keywords(s)
|
||||
match renames.and_then(|at| s.keyword_sets.get(at)) {
|
||||
Some(x) => x.keywords.clone(),
|
||||
None => current_keywords(s),
|
||||
}
|
||||
};
|
||||
keywords.truncate(9);
|
||||
let set = KeywordSet { name: name.clone(), keywords };
|
||||
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
match renames {
|
||||
// rename: in its place, onto a name no other set has
|
||||
Some(at) => {
|
||||
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
if let Some(x) = s.keyword_sets.get_mut(at) {
|
||||
*x = set;
|
||||
}
|
||||
}
|
||||
// a new set (`new`) never takes another set's name
|
||||
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
},
|
||||
}
|
||||
s.keyword_set = Some(name);
|
||||
s.save_prefs()?;
|
||||
@@ -122,11 +229,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
||||
let before = s.keyword_sets.len();
|
||||
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
s.keyword_sets.retain(|x| !same_set(&x.name, name));
|
||||
if s.keyword_sets.len() == before {
|
||||
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
||||
}
|
||||
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
|
||||
s.keyword_set = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
@@ -145,12 +252,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|i| (1..=9).contains(i))
|
||||
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
|
||||
// (an empty slot does nothing)
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
.photo(*id)
|
||||
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
|
||||
});
|
||||
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
let key = if all { "removeKeywords" } else { "addKeywords" };
|
||||
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
||||
@@ -185,6 +297,184 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n)))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "keyword.info",
|
||||
"Keyword Info",
|
||||
[],
|
||||
None,
|
||||
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = named_keyword(s, p, "keyword.info")?;
|
||||
let info = s.catalog.keyword_info(&path).cloned();
|
||||
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
|
||||
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("listed".into(), json!(info.is_some()));
|
||||
o.insert("count".into(), json!(count));
|
||||
o.entry("synonyms").or_insert(json!([]));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.create",
|
||||
"Create Keyword",
|
||||
[],
|
||||
None,
|
||||
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
|
||||
Some(_) => None,
|
||||
None => s.catalog.default_keyword_parent(),
|
||||
};
|
||||
let path = clean(&match parent {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
.map_err(|e| refused("keyword.create", e, ""))?;
|
||||
s.commit("Create Keyword", op)?;
|
||||
if !photos.is_empty() {
|
||||
note_recent(s, std::slice::from_ref(&path));
|
||||
}
|
||||
Ok(json!({"keyword": path}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.edit",
|
||||
"Edit Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.edit")?;
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
|
||||
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
|
||||
let op =
|
||||
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
|
||||
let to = match from.rsplit_once('|') {
|
||||
Some((parent, _)) => format!("{parent}|{}", name.trim()),
|
||||
None => name.trim().to_string(),
|
||||
};
|
||||
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.move",
|
||||
"Move Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.move")?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(Value::Null) => None,
|
||||
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
|
||||
};
|
||||
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
|
||||
Some(parent) => format!("{parent}|{leaf}"),
|
||||
None => leaf,
|
||||
};
|
||||
let op = s
|
||||
.catalog
|
||||
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
|
||||
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
|
||||
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.purgeUnused",
|
||||
"Purge Unused Keywords",
|
||||
[],
|
||||
None,
|
||||
"{} — takes off the keyword list the keywords no photo has → {purged}",
|
||||
always,
|
||||
|s, _| {
|
||||
let op = s.catalog.purge_unused_keywords_ops();
|
||||
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"purged": r["listed"]}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.setDefaultParent",
|
||||
"Put New Keywords Inside",
|
||||
[],
|
||||
None,
|
||||
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
|
||||
always,
|
||||
|s, p| {
|
||||
let keyword = match p.get("keyword") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
|
||||
};
|
||||
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
|
||||
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"keyword": s.catalog.default_keyword_parent()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.export",
|
||||
"Export Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let file = s.catalog.keyword_list_text();
|
||||
// Capture One's importer refuses ; , < > in a keyword's name
|
||||
let refuses: Vec<&str> = file
|
||||
.written
|
||||
.iter()
|
||||
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
|
||||
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
|
||||
.collect();
|
||||
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
|
||||
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
r["path"] = json!(path);
|
||||
}
|
||||
None => r["text"] = json!(file.text),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.import",
|
||||
"Import Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}",
|
||||
always,
|
||||
|s, p| {
|
||||
let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) {
|
||||
(Some(path), _) => read_list(path)?,
|
||||
(None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => {
|
||||
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
(None, Some(text)) => text.to_string(),
|
||||
(None, None) => return Err(bad("keyword.import", "give `path` or `text`")),
|
||||
};
|
||||
let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?;
|
||||
let (op, added, updated) = s.catalog.import_keywords_ops(&entries);
|
||||
commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"added": added, "updated": updated}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.rename",
|
||||
"Rename Keyword",
|
||||
@@ -197,9 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
|
||||
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
|
||||
let (f, t) = (clean(&from), clean(&to));
|
||||
commit_keywords(s, "Rename Keyword", op, |k| {
|
||||
Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() })
|
||||
})
|
||||
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -229,8 +517,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
|
||||
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
|
||||
commit_keywords(s, "Merge Keywords", op, |k| {
|
||||
Some(match from.iter().find(|f| is_under(k, f)) {
|
||||
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
|
||||
Some(match closest(k, &from) {
|
||||
Some(f) => reparent(k, f, &into),
|
||||
None => k.to_string(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
@@ -68,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
}
|
||||
let on_deleted = match str_param(p, "onDeleted") {
|
||||
Some(v) => {
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
@@ -135,6 +149,24 @@ fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
/// The targets an album can hold and how many were browsed (Local) photos, which album views
|
||||
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
|
||||
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
|
||||
let before = targets.len();
|
||||
let kept: Vec<PhotoId> = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect();
|
||||
let skipped = before - kept.len();
|
||||
(kept, skipped)
|
||||
}
|
||||
|
||||
/// Album targets, or the error for a selection that was nothing but Local photos.
|
||||
fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Result<(Vec<PhotoId>, usize)> {
|
||||
let (kept, skipped) = album_targets(s, targets);
|
||||
if kept.is_empty() && skipped > 0 {
|
||||
return Err(bad(command, super::LOCAL_ONLY));
|
||||
}
|
||||
Ok((kept, skipped))
|
||||
}
|
||||
|
||||
/// Apply one op per target as a single undo step.
|
||||
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
@@ -146,6 +178,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
ops.push(Op::SetRating { id, rating });
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
}
|
||||
advance_if(s, p);
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
fn advance_if(s: &mut Session, p: &Value) {
|
||||
if bool_or(p, "advance", false) {
|
||||
let _ = step(s, 1);
|
||||
@@ -169,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();
|
||||
@@ -330,6 +386,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
||||
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
||||
}
|
||||
// checked like a smart album's rules when this call sets them: an unknown field or a value
|
||||
// that can't match is an error, not an empty grid. Rules that stopped checking since
|
||||
// (their album was deleted) don't block other filter changes.
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
|
||||
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
s.filter = f;
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
@@ -339,7 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Folders",
|
||||
[],
|
||||
None,
|
||||
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
always,
|
||||
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
||||
),
|
||||
@@ -387,6 +453,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"removed": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.label",
|
||||
"Set Folder Color Label",
|
||||
[],
|
||||
None,
|
||||
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.label";
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
|
||||
"none" => None,
|
||||
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
|
||||
};
|
||||
// taking a label off always works, also once the folder's photos are gone
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if label.is_none() && s.catalog.folder_record(path).is_none() {
|
||||
return Ok(json!({"path": path, "label": label}));
|
||||
}
|
||||
let op = s.catalog.folder_label_op(path, label);
|
||||
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
|
||||
Ok(json!({"path": path, "label": label}))
|
||||
}
|
||||
),
|
||||
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
||||
s.filter = Default::default();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
@@ -554,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;
|
||||
@@ -564,6 +675,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
advance_if(s, p);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, -1, "Decrease Rating")
|
||||
}),
|
||||
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, 1, "Increase Rating")
|
||||
}),
|
||||
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
|
||||
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
|
||||
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
|
||||
@@ -662,18 +779,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
select_after_deletion(s, &before);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
@@ -699,10 +806,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
s.selection = Selection::default();
|
||||
select_after_deletion(s, &before);
|
||||
Ok(json!({"deleted": t.len()}))
|
||||
}),
|
||||
// ---- metadata
|
||||
@@ -766,16 +874,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords = k;
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
|
||||
use lightcraft_catalog::keywords::{clean, same};
|
||||
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
|
||||
let k = clean(k);
|
||||
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
|
||||
m.keywords.push(k);
|
||||
}
|
||||
};
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords.clear();
|
||||
for k in k {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
for k in strs("removeKeywords").unwrap_or_default() {
|
||||
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
let k = clean(&k);
|
||||
m.keywords.retain(|x| !same(&clean(x), &k));
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
}
|
||||
@@ -790,22 +908,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- albums
|
||||
cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
cmd!(
|
||||
"album.create",
|
||||
"New Album",
|
||||
["File"],
|
||||
None,
|
||||
"{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let (photos, skipped) =
|
||||
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0, "skipped": skipped}))
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"album.createSmart",
|
||||
"New Smart Album…",
|
||||
@@ -819,7 +946,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("album.createSmart", "empty name"));
|
||||
}
|
||||
let rules = match p.get("rules") {
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
|
||||
None => view_rules(s),
|
||||
};
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -829,8 +956,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
@@ -838,13 +965,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Album => "album",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
|
||||
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -855,18 +984,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Smart Album",
|
||||
[],
|
||||
None,
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.setRules")?;
|
||||
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
// the view is saved as it is, but never as a loop (showing A, which tests this
|
||||
// album, and saving that view here would make it test itself)
|
||||
let view = view_rules(s);
|
||||
let loops: Vec<String> = view
|
||||
.rule_set
|
||||
.as_ref()
|
||||
.map(|r| r.check_for(&s.catalog, Some(id)))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(s.catalog.album_filter_problem(&view, Some(id)))
|
||||
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
if !loops.is_empty() {
|
||||
return Err(bad("album.setRules", loops.join("; ")));
|
||||
}
|
||||
view
|
||||
} else {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let folder = cur.library_folder.clone();
|
||||
let base = if replace { Default::default() } else { cur };
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
|
||||
// the rules dialog has no folder field: replacing its rules keeps the folder
|
||||
// unless the call says (`libraryFolder: null`) to drop it
|
||||
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
||||
@@ -874,7 +1019,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
r
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
|
||||
// a new name goes with the rules: one step, applied (and undone) together
|
||||
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
|
||||
match rename {
|
||||
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
|
||||
None => s.commit("Edit Smart Album", set)?,
|
||||
};
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
@@ -884,14 +1035,45 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
s.commit("Delete Album", Op::RemoveAlbum { id })?;
|
||||
if s.source == LibrarySource::Album(id) {
|
||||
s.source = LibrarySource::All;
|
||||
cmd!(
|
||||
"album.delete",
|
||||
"Delete Album",
|
||||
[],
|
||||
None,
|
||||
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
|
||||
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
|
||||
for album in s.catalog.albums() {
|
||||
if let Some(parent) = album.parent {
|
||||
children.entry(parent).or_default().push(album.id);
|
||||
}
|
||||
}
|
||||
// Iterative traversal: even a malformed cycle is rejected before any change.
|
||||
// Reverse parent-first order removes children first; the batch inverse restores
|
||||
// parents first, including memberships, smart rules, covers and manual ordering.
|
||||
let mut pending = vec![id];
|
||||
let mut order = Vec::new();
|
||||
let mut removed = std::collections::BTreeSet::new();
|
||||
while let Some(next) = pending.pop() {
|
||||
if !removed.insert(next) {
|
||||
return Err(bad("album.delete", "cycle in album folders"));
|
||||
}
|
||||
order.push(next);
|
||||
if let Some(nested) = children.remove(&next) {
|
||||
pending.extend(nested);
|
||||
}
|
||||
}
|
||||
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
|
||||
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
|
||||
if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) {
|
||||
s.source = LibrarySource::All;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.move")?;
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -972,34 +1154,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
),
|
||||
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
photos.push(t);
|
||||
cmd!(
|
||||
"album.addPhotos",
|
||||
"Add to Album",
|
||||
["Photo"],
|
||||
None,
|
||||
"{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
photos.push(t);
|
||||
}
|
||||
}
|
||||
let added = photos.len() - before;
|
||||
let cover = al.cover.or(photos.first().copied());
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added, "skipped": skipped}))
|
||||
}
|
||||
let added = photos.len() - before;
|
||||
let cover = al.cover.or(photos.first().copied());
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"album.toggleTarget",
|
||||
"Add to Target Album",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
|
||||
has_selection,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
Some(id) => id,
|
||||
@@ -1023,7 +1215,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
|
||||
)?;
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped}))
|
||||
}
|
||||
),
|
||||
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
|
||||
@@ -1079,7 +1271,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
@@ -1115,7 +1307,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Ok(json!({"imported": [], "folder": folder}));
|
||||
}
|
||||
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
||||
let mut params = json!({"paths": fresh, "mode": mode});
|
||||
// a photo deleted from the library doesn't come back on its own because its file
|
||||
// is still in the watched folder (a copy's source is not a known path)
|
||||
let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"});
|
||||
if let Some(a) = s.import_defaults.auto_album.clone() {
|
||||
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
|
||||
Some(al) => params["album"] = json!(al.id.0),
|
||||
@@ -1133,7 +1327,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
s.selection = sel;
|
||||
Ok(json!({"imported": r["imported"], "folder": folder}))
|
||||
let mut result = json!({"imported": r["imported"], "folder": folder});
|
||||
if let Some(warnings) = r.get("warnings") {
|
||||
result["warnings"] = warnings.clone();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
),
|
||||
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
|
||||
@@ -1195,7 +1393,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let req = import_params(s, p)?;
|
||||
@@ -1283,12 +1481,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// `base` with a partial Filter (JSON) merged on top.
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
|
||||
/// [`merge_rules`]).
|
||||
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
|
||||
return Err(bad(c, problem));
|
||||
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
|
||||
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
|
||||
/// problem too).
|
||||
fn merge_rules(
|
||||
base: &lightcraft_catalog::Filter,
|
||||
patch: &Value,
|
||||
c: &str,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
owner: Option<AlbumId>,
|
||||
) -> Result<lightcraft_catalog::Filter> {
|
||||
let f = merge_filter(base, patch, c)?;
|
||||
// only rules this change sets are checked: one that stopped checking since (its album was
|
||||
// deleted) doesn't block editing the album's other settings
|
||||
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
|
||||
// and a loop through the album field the change sets
|
||||
if patch.get("album").is_some_and(|a| !a.is_null()) {
|
||||
problems.extend(cat.album_filter_problem(&f, owner));
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
@@ -1296,7 +1521,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
@@ -1324,7 +1549,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
}
|
||||
}
|
||||
}
|
||||
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
// the view as it is: a rule that no longer checks (its album was deleted) just matches
|
||||
// nothing there, and mustn't cost the filter bar's settings
|
||||
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
}
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
@@ -1392,3 +1619,7 @@ mod gps_tests {
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library/tests_relative_ratings.rs"]
|
||||
mod relative_rating_tests;
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
fn session() -> Result<Session> {
|
||||
let mut s = Session::new();
|
||||
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
|
||||
let mut photo = Photo::new(
|
||||
PhotoId(id),
|
||||
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
|
||||
&format!("{id}.jpg"),
|
||||
"JPEG",
|
||||
40,
|
||||
30,
|
||||
"2026-10-09",
|
||||
);
|
||||
photo.rating = rating;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
|
||||
}
|
||||
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
|
||||
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn ratings(s: &Session) -> Result<Vec<u8>> {
|
||||
(1..=4)
|
||||
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
|
||||
let mut s = session().unwrap();
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.active(), Some(PhotoId(2)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
|
||||
let mut s = session().unwrap();
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
|
||||
assert!(s.undo.is_empty());
|
||||
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
let undo = s.undo.len();
|
||||
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
|
||||
assert_eq!(s.active(), Some(PhotoId(3)));
|
||||
s.selection = Selection::default();
|
||||
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_and_history_use_auto_write() {
|
||||
let mut s = session().unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
file_name: format!("{id}.jpg"),
|
||||
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute("photo.increaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
|
||||
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
s.execute("photo.decreaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -34,22 +34,34 @@ pub fn register_all(s: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened).
|
||||
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't
|
||||
/// be read with the reason (`[name, reason]`).
|
||||
fn cube_files(paths: &[String]) -> (Vec<(String, String, Option<String>)>, Vec<Value>) {
|
||||
// (name, text, group)
|
||||
let mut out = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
|
||||
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
|
||||
if let Ok(t) = std::fs::read_to_string(&ep) {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
match std::fs::read_to_string(&ep) {
|
||||
Ok(t) => {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((stem(&ep), t, group));
|
||||
}
|
||||
Err(e) => failed.push(json!([stem(&ep), e.to_string()])),
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
let Ok(b) = std::fs::read(path) else { continue };
|
||||
let b = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failed.push(json!([stem(path), e.to_string()]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
|
||||
if inner.to_ascii_lowercase().ends_with(".cube") {
|
||||
let ip = Path::new(&inner);
|
||||
@@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Ok(t) = std::fs::read_to_string(path) {
|
||||
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
|
||||
} else {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(t) => out.push((stem(path), t, None)),
|
||||
Err(e) => failed.push(json!([stem(path), e.to_string()])),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, failed)
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = &dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for (name, text, group) in cube_files(&paths) {
|
||||
let (files, mut failed) = cube_files(&paths);
|
||||
let mut imported = Vec::new();
|
||||
for (name, text, group) in files {
|
||||
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
|
||||
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
// photos without a capture time start from their import time
|
||||
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
|
||||
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
|
||||
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
|
||||
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
|
||||
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
|
||||
if shift.is_nan() || shift.abs() > MAX_SHIFT {
|
||||
return Err(bad(c, "the shift is more than 10,000 years"));
|
||||
}
|
||||
let mut delta = shift.round() as i64;
|
||||
let mut each: Option<String> = None;
|
||||
if let Some(t) = str_param(p, "time") {
|
||||
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
|
||||
@@ -197,23 +203,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
each = Some(t);
|
||||
} else {
|
||||
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
|
||||
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
let anchor_date = base(s, anchor);
|
||||
// An impossible calendar date cannot anchor changes to the other photos.
|
||||
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
|
||||
.and_then(|_| iso_seconds(&anchor_date))
|
||||
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut out_of_range = 0usize;
|
||||
for id in &targets {
|
||||
let new = match &each {
|
||||
Some(t) => shift_iso(t, delta),
|
||||
None => shift_iso(&base(s, *id), delta),
|
||||
let from = match &each {
|
||||
Some(t) => t.clone(),
|
||||
None => base(s, *id),
|
||||
};
|
||||
let Some(new) = shift_iso(&from, delta) else {
|
||||
// a date that reads but would leave years 0000–9999 is refused below; one
|
||||
// that doesn't read is skipped, as before
|
||||
if iso_seconds(&from).is_some() {
|
||||
out_of_range += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(new) = new else { continue };
|
||||
out.push(json!(new));
|
||||
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
|
||||
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
|
||||
}
|
||||
}
|
||||
if out_of_range > 0 {
|
||||
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Edit Capture Time", Op::Batch { ops })?;
|
||||
|
||||
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let m = &mut masks[i];
|
||||
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
@@ -464,7 +464,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
));
|
||||
}
|
||||
let started = s.segmenter.start_download().map_err(ai_err)?;
|
||||
let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?;
|
||||
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
|
||||
}
|
||||
),
|
||||
@@ -690,12 +690,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.findBlemishes",
|
||||
"Find Blemishes",
|
||||
[],
|
||||
None,
|
||||
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
|
||||
has_active,
|
||||
|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))?;
|
||||
// the uncropped photo, so positions are the spots' own coordinates
|
||||
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
|
||||
let faces =
|
||||
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
|
||||
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
|
||||
.iter()
|
||||
.map(|f| lightcraft_pipeline::blemish::FaceBox {
|
||||
x0: f64::from(f.x0),
|
||||
y0: f64::from(f.y0),
|
||||
x1: f64::from(f.x1),
|
||||
y1: f64::from(f.y1),
|
||||
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
|
||||
})
|
||||
.collect();
|
||||
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
|
||||
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
|
||||
let mut per_face = std::collections::BTreeMap::new();
|
||||
found.retain(|b| {
|
||||
let n = per_face.entry(b.face).or_insert(0usize);
|
||||
*n += 1;
|
||||
*n <= max_per_face
|
||||
});
|
||||
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
|
||||
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
|
||||
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
|
||||
}
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let base = Spot::default();
|
||||
for b in &found {
|
||||
let mut spot = Spot {
|
||||
mode: SpotMode::Heal,
|
||||
points: vec![Point::new(b.x, b.y)],
|
||||
size: b.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: base.feather,
|
||||
opacity: 100.0,
|
||||
source_offset: None,
|
||||
};
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
dd.spots.push(spot);
|
||||
}
|
||||
s.set_develop(id, dd, "Find Blemishes")?;
|
||||
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.add",
|
||||
"Add Remove Spot",
|
||||
[],
|
||||
None,
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mode: SpotMode =
|
||||
@@ -719,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if spot.source_offset.is_none() {
|
||||
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
|
||||
// resolve the automatic source now: it gets a pin and stays put at every size
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
Loaded 100 of 467 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user