Compare commits
671
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc0dde747 | ||
|
|
88ea1a804b | ||
|
|
53711eb1ef | ||
|
|
1727477382 | ||
|
|
03b3d606ce | ||
|
|
de087c0c6f | ||
|
|
da6cbcf968 | ||
|
|
95d8aed986 | ||
|
|
38431cb4db | ||
|
|
abe4c5fe1b | ||
|
|
c6745bc6f7 | ||
|
|
093c948f8b | ||
|
|
aa1daf5dc9 | ||
|
|
38b8ebdfa5 | ||
|
|
5328100722 | ||
|
|
6b671c9912 | ||
|
|
b7e2f4eff4 | ||
|
|
eae3df481d | ||
|
|
a276febabe | ||
|
|
967b858129 | ||
|
|
d31f805945 | ||
|
|
12a2a99f2a | ||
|
|
5e6d33065a | ||
|
|
65ad290e89 | ||
|
|
e111734532 | ||
|
|
8f924a9e60 | ||
|
|
7a42f99cf0 | ||
|
|
081a619c51 | ||
|
|
7e350eccb8 | ||
|
|
8abcd2fc54 | ||
|
|
b7331d6c13 | ||
|
|
5bd4302037 | ||
|
|
1b4e646df5 | ||
|
|
9fef5ac702 | ||
|
|
0b6cfd89ec | ||
|
|
56b06426db | ||
|
|
d418292fb0 | ||
|
|
11e739cd02 | ||
|
|
7317e64d74 | ||
|
|
ceb35048bb | ||
|
|
f731922e16 | ||
|
|
a18a6e6a5e | ||
|
|
ffd91332e9 | ||
|
|
8d96dab1af | ||
|
|
761631c56c | ||
|
|
0a4dc3f3ec | ||
|
|
9eb8151984 | ||
|
|
cdc2e57776 | ||
|
|
369c56a6ed | ||
|
|
b9f951998f | ||
|
|
c691bcfa02 | ||
|
|
2ec2b5bf4a | ||
|
|
e5ff5461e3 | ||
|
|
44735cd5e7 | ||
|
|
c5449926b9 | ||
|
|
fcef4c0fc9 | ||
|
|
0778874d13 | ||
|
|
24427912cb | ||
|
|
da62ea8c49 | ||
|
|
cfca299945 | ||
|
|
fa1565d855 | ||
|
|
bbcd2f47e8 | ||
|
|
5fc85ee034 | ||
|
|
cec18712d6 | ||
|
|
3af7a0c0ae | ||
|
|
e7da5d34a2 | ||
|
|
1399c445ef | ||
|
|
a45c2b661b | ||
|
|
c7719ddcbf | ||
|
|
bf35a3c6c5 | ||
|
|
d9fd2f8ae0 | ||
|
|
1206d3fd52 | ||
|
|
35718bc92c | ||
|
|
dedc049692 | ||
|
|
5c16320f6b | ||
|
|
c435d143de | ||
|
|
82fc317922 | ||
|
|
40652fc5f9 | ||
|
|
989422398c | ||
|
|
68e45585d0 | ||
|
|
7dcfbed05c | ||
|
|
d9457e6b23 | ||
|
|
5c1dcd4f97 | ||
|
|
1e1eb5fe16 | ||
|
|
c65065de2f | ||
|
|
3e76713c1d | ||
|
|
79b19fd886 | ||
|
|
82f904495f | ||
|
|
ae2f06a035 | ||
|
|
b341101ab6 | ||
|
|
7365d06c13 | ||
|
|
ab73d3d2e0 | ||
|
|
c4c96ecabb | ||
|
|
cae7bea08e | ||
|
|
24615cc2d7 | ||
|
|
d0749ed7a9 | ||
|
|
bb7c46ae3c | ||
|
|
7db3b8a1f9 | ||
|
|
a628d5bc38 | ||
|
|
42f966eec2 | ||
|
|
ca97329ae1 | ||
|
|
3368eb49ee | ||
|
|
cbf9935b6e | ||
|
|
4977c648f6 | ||
|
|
91d317900f | ||
|
|
56754e5703 | ||
|
|
04e8aea577 | ||
|
|
1a2d2ab975 | ||
|
|
1f30856774 | ||
|
|
05769798f6 | ||
|
|
59ab9b6e9b | ||
|
|
111cc893ec | ||
|
|
24f87a91a0 | ||
|
|
20a801d6d2 | ||
|
|
03b665e7a3 | ||
|
|
fd4019300a | ||
|
|
7935999d94 | ||
|
|
6e8db91673 | ||
|
|
7845d515e8 | ||
|
|
cb34bb4842 | ||
|
|
7ca7113b32 | ||
|
|
39de0e6a1c | ||
|
|
3c3b527b7f | ||
|
|
3d9f8492da | ||
|
|
a12f1697a1 | ||
|
|
0ac5cf4430 | ||
|
|
ce7b8f511e | ||
|
|
ed59d78e83 | ||
|
|
a28dc4dbdf | ||
|
|
1ed80c65bf | ||
|
|
b1883231bd | ||
|
|
733846b326 | ||
|
|
8ff45f5237 | ||
|
|
5fefe91640 | ||
|
|
24b80b4ee3 | ||
|
|
922ea25d88 | ||
|
|
085c25b10b | ||
|
|
8036f98aca | ||
|
|
34a4b61f7e | ||
|
|
2c5cb708bb | ||
|
|
39f5f114de | ||
|
|
67aed0b4f4 | ||
|
|
63335e2b0a | ||
|
|
aaae39fae0 | ||
|
|
5443ae28f9 | ||
|
|
4abf70e254 | ||
|
|
79403a5680 | ||
|
|
96a9c6e04d | ||
|
|
e8b18ae636 | ||
|
|
a46c0071a0 | ||
|
|
12188161f9 | ||
|
|
de79310ae0 | ||
|
|
caa102aabd | ||
|
|
373efc280e | ||
|
|
c75389dda1 | ||
|
|
8ee6d63d41 | ||
|
|
f578b27273 | ||
|
|
43419dcfb8 | ||
|
|
83ea5e14df | ||
|
|
eab88ea30c | ||
|
|
3e88a60dd6 | ||
|
|
0cf3ceb438 | ||
|
|
bba4172f83 | ||
|
|
93b900f8d7 | ||
|
|
0cb78071de | ||
|
|
fec379db46 | ||
|
|
4d2f34fb35 | ||
|
|
b306305472 | ||
|
|
832b8ada51 | ||
|
|
bf97105cf7 | ||
|
|
eaaf859853 | ||
|
|
2bd13c1500 | ||
|
|
cc8d617459 | ||
|
|
d3ceaf0e79 | ||
|
|
32cedb3153 | ||
|
|
86d88de2ee | ||
|
|
d01ebd7953 | ||
|
|
54b037a90a | ||
|
|
1e27991cf1 | ||
|
|
7173242ab5 | ||
|
|
31aa638383 | ||
|
|
09d5284e5a | ||
|
|
7e0250e11e | ||
|
|
9087d3ad7f | ||
|
|
a1e99fad9e | ||
|
|
ff9d74f4e5 | ||
|
|
55ecd900ae | ||
|
|
2d47ba3207 | ||
|
|
7b47ba7894 | ||
|
|
826ba91075 | ||
|
|
22b7ba09b9 | ||
|
|
057a8d308e | ||
|
|
1b5ba5eba7 | ||
|
|
ddaea580a1 | ||
|
|
03249f4619 | ||
|
|
495b1ac9fb | ||
|
|
f1a513ffe2 | ||
|
|
a5b9dd603f | ||
|
|
a94b647dfd | ||
|
|
f68cd69b1e | ||
|
|
e9fb4b08e1 | ||
|
|
8a5ecebc18 | ||
|
|
eb06b65fdb | ||
|
|
92196fddeb | ||
|
|
ea33301e6e | ||
|
|
2f7d248d61 | ||
|
|
35c9b67944 | ||
|
|
6a07c35431 | ||
|
|
72bab50c6e | ||
|
|
136b2851ee | ||
|
|
83d0304ce6 | ||
|
|
3255cda67e | ||
|
|
b7f632f5c0 | ||
|
|
63cfc6ab70 | ||
|
|
930d14aacd | ||
|
|
ff9c3fb2ec | ||
|
|
a1d907bf0f | ||
|
|
05618b2ad4 | ||
|
|
4b83f8bb5e | ||
|
|
eebea486d5 | ||
|
|
dc5bff6947 | ||
|
|
f8f091436a | ||
|
|
244088498d | ||
|
|
e0539db3f5 | ||
|
|
2079e8e08d | ||
|
|
b358022f14 | ||
|
|
ca5c212d03 | ||
|
|
14570cfdf3 | ||
|
|
8d79bf082e | ||
|
|
d316d97d80 | ||
|
|
2ab6e973c6 | ||
|
|
c395789026 | ||
|
|
6aa599f869 | ||
|
|
f6f5e6f1b7 | ||
|
|
7094ba1b6e | ||
|
|
cb0e0cb2e5 | ||
|
|
0fb3351c91 | ||
|
|
b9254242a2 | ||
|
|
ab9e8d372c | ||
|
|
b11e5399c6 | ||
|
|
83058f717e | ||
|
|
7962b66a06 | ||
|
|
c140f63372 | ||
|
|
790a341776 | ||
|
|
67b7bc8bc6 | ||
|
|
41e67e1edd | ||
|
|
5d437489b9 | ||
|
|
1e6f0e28e1 | ||
|
|
4af709995f | ||
|
|
9b0d0fd05f | ||
|
|
b1d8ba4284 | ||
|
|
d8efa6a147 | ||
|
|
d4c0a7a3d4 | ||
|
|
68e0827633 | ||
|
|
cad024fbff | ||
|
|
86deb5b004 | ||
|
|
a673b2bb7a | ||
|
|
305ff71474 | ||
|
|
a0e49629d3 | ||
|
|
c8f697a128 | ||
|
|
232cbace63 | ||
|
|
1fa3af8740 | ||
|
|
8e4d7545ff | ||
|
|
1028ed6b88 | ||
|
|
efe7cad73c | ||
|
|
a86defd1b9 | ||
|
|
881bc85dac | ||
|
|
dbe73f0f9c | ||
|
|
0b8c7a237a | ||
|
|
43aa795539 | ||
|
|
38cdd49274 | ||
|
|
0b587a0a8a | ||
|
|
60a98354d9 | ||
|
|
f16f413942 | ||
|
|
48cd9c5e3a | ||
|
|
e2b626b7f7 | ||
|
|
28db7b200b | ||
|
|
3f63db0a0b | ||
|
|
f611e76d0e | ||
|
|
eeb136b9ee | ||
|
|
3f10ba4f3b | ||
|
|
7fb24029f2 | ||
|
|
5601d7879a | ||
|
|
095c7dce87 | ||
|
|
f96f732baa | ||
|
|
bf224c2a8d | ||
|
|
16236ff779 | ||
|
|
2592ab47e9 | ||
|
|
3e791905b9 | ||
|
|
2897f85861 | ||
|
|
3517606ffc | ||
|
|
9e51ca0da8 | ||
|
|
85e38c7342 | ||
|
|
4836143f9e | ||
|
|
57dc811a6a | ||
|
|
9e327b4772 | ||
|
|
f6fcef4d5c | ||
|
|
3895cf6411 | ||
|
|
5cb74e7313 | ||
|
|
4165904bd1 | ||
|
|
6ea9b979c8 | ||
|
|
e73d88a55f | ||
|
|
d56d265044 | ||
|
|
b4a1166f4b | ||
|
|
5413c8bb3a | ||
|
|
c07395222f | ||
|
|
7b082660e7 | ||
|
|
3a9b6ccee4 | ||
|
|
07d9678209 | ||
|
|
8980c3bb0e | ||
|
|
3c76902bbb | ||
|
|
1ebce934ea | ||
|
|
7cf3719c63 | ||
|
|
c3dce49c2e | ||
|
|
3af89e6ed5 | ||
|
|
a7fcd81850 | ||
|
|
c3c30dae3f | ||
|
|
47cf1ade6f | ||
|
|
00388f3fd2 | ||
|
|
1b4518c202 | ||
|
|
d16ab1f238 | ||
|
|
bbcfdf085b | ||
|
|
1e6035d9db | ||
|
|
37cfc46e0d | ||
|
|
b256b8b635 | ||
|
|
e8c817ddea | ||
|
|
d367d83ee6 | ||
|
|
6a05f865ee | ||
|
|
866d02e577 | ||
|
|
4a427894ed | ||
|
|
aaa46003d5 | ||
|
|
a9cd761d52 | ||
|
|
68267810b3 | ||
|
|
9b4ba1aa80 | ||
|
|
0634782fbc | ||
|
|
9b5e771fd8 | ||
|
|
69384ad430 | ||
|
|
acc85c4147 | ||
|
|
e2d35de867 | ||
|
|
0fbf20db51 | ||
|
|
2b66eb4800 | ||
|
|
92c7237c50 | ||
|
|
34ecea4f24 | ||
|
|
ff8d027766 | ||
|
|
4fb8ad4dcd | ||
|
|
dc67d348e6 | ||
|
|
7f386aa258 | ||
|
|
399aedd23a | ||
|
|
b5fcf58e55 | ||
|
|
261994acbc | ||
|
|
b936c1a683 | ||
|
|
cab6af8608 | ||
|
|
4eb9b565e0 | ||
|
|
ccdd9e45d1 | ||
|
|
24c800f46f | ||
|
|
e471e7c346 | ||
|
|
aaec05dd11 | ||
|
|
d808749792 | ||
|
|
c03a67c59f | ||
|
|
cd8d2b96a2 | ||
|
|
a12fabd588 | ||
|
|
99fa19099b | ||
|
|
bac4666e7f | ||
|
|
b7f60a1fac | ||
|
|
4d506494d9 | ||
|
|
90fe6d1616 | ||
|
|
fa630a233a | ||
|
|
9e9e45b3c7 | ||
|
|
259de3d9df | ||
|
|
8b70d33d0f | ||
|
|
5e2ece0cf3 | ||
|
|
53584512f4 | ||
|
|
e4737f68c7 | ||
|
|
21de86b3df | ||
|
|
0b101be155 | ||
|
|
7d2bb5e5ec | ||
|
|
3a9fe13d3a | ||
|
|
2565f893c4 | ||
|
|
4e2584e559 | ||
|
|
cb173c22dc | ||
|
|
98da2a0229 | ||
|
|
1ece9341d6 | ||
|
|
6d52e0744a | ||
|
|
dd783b1d38 | ||
|
|
81e28f25c9 | ||
|
|
b5b6f8caa8 | ||
|
|
7e21d185b8 | ||
|
|
70db2f2320 | ||
|
|
74b9129a05 | ||
|
|
36c6920f55 | ||
|
|
6d22d1235b | ||
|
|
5f9548a7ba | ||
|
|
0e188f6a40 | ||
|
|
a66eebf778 | ||
|
|
e3dc820d89 | ||
|
|
a6cedcc9e8 | ||
|
|
f2bcc9219c | ||
|
|
0613f925de | ||
|
|
853ada7754 | ||
|
|
493600743d | ||
|
|
e4ab8422b4 | ||
|
|
0b5ef3fe11 | ||
|
|
38d2ff07c9 | ||
|
|
6c19090fde | ||
|
|
5b80fc6bcf | ||
|
|
0a474cc06e | ||
|
|
2928880e81 | ||
|
|
14a03052de | ||
|
|
7f6e4e48f7 | ||
|
|
912f56cac8 | ||
|
|
0e254bfbc6 | ||
|
|
8d8178dc0a | ||
|
|
90c63af951 | ||
|
|
69640df8a7 | ||
|
|
a47afdc1f4 | ||
|
|
2472021091 | ||
|
|
b4a192a54e | ||
|
|
60828c3eaf | ||
|
|
ebe54f934e | ||
|
|
4256865cf4 | ||
|
|
57c9e252c1 | ||
|
|
46c4a2709d | ||
|
|
198286e589 | ||
|
|
6f33aacd22 | ||
|
|
5d13d59520 | ||
|
|
0732edc64f | ||
|
|
82e827b9d9 | ||
|
|
88c1e768f1 | ||
|
|
cc425d0cea | ||
|
|
6277af55bf | ||
|
|
b8df5f472c | ||
|
|
30f9eb3674 | ||
|
|
7177b9b9f3 | ||
|
|
c60482f753 | ||
|
|
c1861e5416 | ||
|
|
88d9b7196d | ||
|
|
47f40eb2d5 | ||
|
|
39e04ae7ab | ||
|
|
ee0b0ef67e | ||
|
|
b4ca9f7439 | ||
|
|
169a4ed059 | ||
|
|
cfcc169ff1 | ||
|
|
5923be31d6 | ||
|
|
f7c0b9e49f | ||
|
|
b7e37a388d | ||
|
|
87dd1c201a | ||
|
|
35dd973d91 | ||
|
|
49868693d5 | ||
|
|
79fbb6b464 | ||
|
|
95b57d3786 | ||
|
|
782ade6c39 | ||
|
|
11d6676984 | ||
|
|
841850d516 | ||
|
|
15feb6e1fc | ||
|
|
03bc1469f9 | ||
|
|
4e012a59a6 | ||
|
|
6c7405ed3e | ||
|
|
8dd3afce51 | ||
|
|
4b57d5dc5a | ||
|
|
6a34a1f8b8 | ||
|
|
fc4b054fa4 | ||
|
|
d614dc72bd | ||
|
|
024e983a7e | ||
|
|
629e39380e | ||
|
|
1ba0421a85 | ||
|
|
8734e95dc8 | ||
|
|
4159ceb1b0 | ||
|
|
2694d7089f | ||
|
|
88f38c7e2a | ||
|
|
df574a537a | ||
|
|
5bc923b4f9 | ||
|
|
71c8382b10 | ||
|
|
0a952b3536 | ||
|
|
f9cfe15db6 | ||
|
|
ed0a2a4f07 | ||
|
|
0801219b17 | ||
|
|
e716ede8e4 | ||
|
|
2e48f25997 | ||
|
|
d716d74af5 | ||
|
|
a1c5e2dad1 | ||
|
|
c816ca8e35 | ||
|
|
62325bd45e | ||
|
|
4d5504cf16 | ||
|
|
aec820033b | ||
|
|
b90c06b347 | ||
|
|
52f64ce0b9 | ||
|
|
767ed15adc | ||
|
|
1c025fbb97 | ||
|
|
9b41e33a24 | ||
|
|
0143704c7d | ||
|
|
9da8163ae2 | ||
|
|
46f5d212e7 | ||
|
|
eba0b9723b | ||
|
|
5372309ad8 | ||
|
|
b63ea8f5c2 | ||
|
|
2dda7cf842 | ||
|
|
be87e1b6e7 | ||
|
|
97e34434d0 | ||
|
|
30192541ea | ||
|
|
c4d7c00e74 | ||
|
|
3fe0bef364 | ||
|
|
590b387a53 | ||
|
|
51c878ddd8 | ||
|
|
034bb6e324 | ||
|
|
41f9ae0c2b | ||
|
|
e2a941887e | ||
|
|
e574839724 | ||
|
|
f0632e7022 | ||
|
|
d3c6dc0aa8 | ||
|
|
ce165dfa4a | ||
|
|
1237e198d5 | ||
|
|
2b2ebfc7ca | ||
|
|
6b6e756830 | ||
|
|
e9931d6871 | ||
|
|
b4dc8c10ee | ||
|
|
73cf0d50bb | ||
|
|
c7f5664ce2 | ||
|
|
dbc05bedc7 | ||
|
|
f2f7a1cb26 | ||
|
|
7f3d9ce25f | ||
|
|
f73aedcc94 | ||
|
|
928c2c974f | ||
|
|
ee9a989834 | ||
|
|
94fe30015c | ||
|
|
e99ce5a578 | ||
|
|
6aebd48eec | ||
|
|
030ccf9782 | ||
|
|
6b0274120b | ||
|
|
855d3a7959 | ||
|
|
fb01966a00 | ||
|
|
520d7b8616 | ||
|
|
a15bd574f9 | ||
|
|
b040f9bb8f | ||
|
|
036993f194 | ||
|
|
7aab3fd97f | ||
|
|
6de619ca9b | ||
|
|
89446df6a8 | ||
|
|
b310de1bd6 | ||
|
|
17daca8a87 | ||
|
|
a72118ec6e | ||
|
|
c58128c36b | ||
|
|
94780ed299 | ||
|
|
cabe5b7399 | ||
|
|
7fa7506db4 | ||
|
|
6bd64e4ad4 | ||
|
|
9089092969 | ||
|
|
6408ccf244 | ||
|
|
517c745fad | ||
|
|
30225fb6a0 | ||
|
|
b35487add2 | ||
|
|
65bc68fd33 | ||
|
|
d5be201431 | ||
|
|
d379e780cc | ||
|
|
b52f43d1d5 | ||
|
|
8148988eb8 | ||
|
|
fefd61c967 | ||
|
|
707138f3fe | ||
|
|
fb9fb7717e | ||
|
|
a5966f9b2e | ||
|
|
623122c1a9 | ||
|
|
a52c7448e4 | ||
|
|
27d6bc7790 | ||
|
|
f2f449611d | ||
|
|
288fd3f125 | ||
|
|
1779216514 | ||
|
|
f0bb7c768e | ||
|
|
2eff66dd1e | ||
|
|
ca0c569863 | ||
|
|
549cbe728c | ||
|
|
4e0f8ced0c | ||
|
|
862c1f253a | ||
|
|
4ded49ecd9 | ||
|
|
269934caca | ||
|
|
8c253b28ad | ||
|
|
87a58b4183 | ||
|
|
60a8649779 | ||
|
|
313c555b50 | ||
|
|
3793e8fc33 | ||
|
|
1b22a0e53b | ||
|
|
d814a31497 | ||
|
|
889b610e9f | ||
|
|
eacce855b9 | ||
|
|
3757506f46 | ||
|
|
01c9b743fe | ||
|
|
db2d94a233 | ||
|
|
2c2694ff1e | ||
|
|
136c1a0d89 | ||
|
|
9ef83ae3ae | ||
|
|
51f0ef25ae | ||
|
|
1ce28c3aea | ||
|
|
db640ed1d3 | ||
|
|
987d14b6ad | ||
|
|
d4b73b7f64 | ||
|
|
489f78ba69 | ||
|
|
22eb4d3515 | ||
|
|
9adb40908a | ||
|
|
c9fe78e178 | ||
|
|
13a3eff2e8 | ||
|
|
6bda431c4b | ||
|
|
22c6aa6918 | ||
|
|
9bb06cc9dc | ||
|
|
e5848aa1b2 | ||
|
|
e2e6db0f14 | ||
|
|
b9621eb5a3 | ||
|
|
8859f797d8 | ||
|
|
7c27c3a920 | ||
|
|
f5f8bc5d34 | ||
|
|
9715c90413 | ||
|
|
f1b2d9af11 | ||
|
|
f29436a1f6 | ||
|
|
6468bfc06b | ||
|
|
5cee0c08d0 | ||
|
|
6ad215e5b9 | ||
|
|
33a84b8427 | ||
|
|
265248c86f | ||
|
|
1130e99095 | ||
|
|
07a7316d8b | ||
|
|
2aa236537f | ||
|
|
bd3185ef4d | ||
|
|
4cf883155a | ||
|
|
71eac70696 | ||
|
|
b66cdb23a3 | ||
|
|
d2359ce095 | ||
|
|
7a2596cd43 | ||
|
|
8bef0b1ef3 | ||
|
|
a2dbefd1ee | ||
|
|
f5bd3cc2db | ||
|
|
1969b311cc | ||
|
|
fc3fa1a71b | ||
|
|
d0612b0b21 | ||
|
|
66dc737ff5 | ||
|
|
fec3b15234 | ||
|
|
11ebb3bdb6 | ||
|
|
ad2525ece2 | ||
|
|
2c94b7fa1f | ||
|
|
65da36675f | ||
|
|
cbb6c3b8f7 | ||
|
|
5b414ffb8e | ||
|
|
f7a7831380 | ||
|
|
2b2ade7a23 | ||
|
|
d6c7228dd4 | ||
|
|
4b3048c48f | ||
|
|
bc4799ca42 | ||
|
|
586479a309 | ||
|
|
87f275b58e | ||
|
|
894759e6d8 | ||
|
|
1952f7c960 | ||
|
|
7bbdbc03ef | ||
|
|
0f34a65a8c | ||
|
|
7b59081d22 | ||
|
|
50cd8dc6dc | ||
|
|
cc8b3eb3bb | ||
|
|
a6b402981b | ||
|
|
4deb5eff04 | ||
|
|
6e2408ff7d | ||
|
|
66be31e3d8 | ||
|
|
332f37a5c7 | ||
|
|
aa1af6d5e9 | ||
|
|
963912b231 | ||
|
|
4174ce8c57 | ||
|
|
f28e1c362e | ||
|
|
70d800ba68 | ||
|
|
33ef91a123 | ||
|
|
f1b29cfe4a | ||
|
|
130b807e1b | ||
|
|
43f4e24355 | ||
|
|
ebc73bb7b4 | ||
|
|
509d5843dd | ||
|
|
05f242c445 | ||
|
|
87e0181690 | ||
|
|
7c799a9acf |
No files matched your search
@@ -27,6 +27,12 @@ jobs:
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Optional font build input, so the Japanese font tests run (they skip without it).
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
@@ -44,5 +50,6 @@ jobs:
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
@@ -47,7 +47,22 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
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
|
||||
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"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft' and m['command'] == 'lightcraft'"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD tarball layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
+143
-13
@@ -1,9 +1,12 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "LightCraft v<version>". See docs/releasing.md.
|
||||
# Release pipeline: every push to `release` builds platform artifacts (signed where credentials
|
||||
# are configured) and creates or updates the draft GitHub Release "LightCraft v<version>".
|
||||
# See docs/releasing.md.
|
||||
#
|
||||
# All jobs 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 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.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -35,7 +38,7 @@ jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# No `environment: release` here or on the other jobs that sign nothing: no release secrets.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
@@ -63,13 +66,19 @@ jobs:
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -87,6 +96,8 @@ jobs:
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
@@ -112,11 +123,20 @@ jobs:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -133,6 +153,8 @@ jobs:
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -154,7 +176,6 @@ jobs:
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -167,12 +188,18 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
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"
|
||||
@@ -184,6 +211,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
@@ -194,6 +224,9 @@ jobs:
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -201,17 +234,110 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
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
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
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
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
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
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -221,13 +347,17 @@ jobs:
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -237,7 +367,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'LightCraft'
|
||||
foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'lightcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok lightcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
@@ -7,3 +7,10 @@ plan/
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/
|
||||
/craft-fonts
|
||||
# nix build / nix develop
|
||||
/result
|
||||
/result-*
|
||||
# model weights are never committed (SAM 3: docs/ai-masks.md)
|
||||
*.safetensors
|
||||
*.safetensors.part
|
||||
@@ -1,6 +1,6 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -62,10 +62,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
|
||||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
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,
|
||||
`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
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
|
||||
@@ -108,4 +123,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
@@ -1,6 +1,6 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -62,10 +62,25 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
|
||||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
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,
|
||||
`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
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
|
||||
@@ -108,4 +123,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
Generated
+1834
-32
File diff suppressed because it is too large.
Load diff
+13
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -11,7 +11,10 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-faces = { path = "crates/faces" }
|
||||
lightcraft-sysmem = { path = "crates/sysmem" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
@@ -29,9 +32,13 @@ lightcraft-merge = { path = "crates/merge" }
|
||||
lightcraft-engine = { path = "crates/engine" }
|
||||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
@@ -59,9 +66,14 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
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)
|
||||
|
||||
# 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
|
||||
# Oniguruma C library into candle-core, and the product is pure Rust.
|
||||
candle-core = { version = "=0.9.2", default-features = false }
|
||||
candle-nn = { version = "=0.9.2", default-features = false }
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "parking_lot", "metal", "vulkan", "dx12", "wgsl"] }
|
||||
pollster = "1"
|
||||
|
||||
|
||||
@@ -15,6 +15,29 @@ 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.
|
||||
|
||||
BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors
|
||||
(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1;
|
||||
see assets/fonts/OFL-BIZUDMincho.txt.
|
||||
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.
|
||||
The port was modified by the LightCraft contributors (2026): translated from Python/PyTorch to
|
||||
Rust on candle and restructured; each ported file says so. Because of this, crates/segment is
|
||||
licensed under the Apache License 2.0 only (LICENSE-APACHE), not under the MIT option.
|
||||
|
||||
The SAM 3 weights are not part of LightCraft and are never distributed with it. A user downloads
|
||||
the facebook/sam3 checkpoint only when they choose to (in the app, or by hand), under Meta's SAM
|
||||
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
|
||||
licence. See docs/ai-masks.md.
|
||||
|
||||
AI denoise weights are not part of LightCraft and are never distributed with it.
|
||||
The default build offers user-supplied models only. The optional rawnind-model
|
||||
feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under
|
||||
GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms.
|
||||
Commercial use is subject to those terms, including applicable source, licence and
|
||||
notice obligations on redistribution. Enabling that offer in releases remains a
|
||||
maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implementation;
|
||||
no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
|
||||
|
||||
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
|
||||
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
|
||||
(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.
|
||||
@@ -59,6 +59,7 @@
|
||||
<a href="#feature-status">Status</a> ·
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="ROADMAP.md">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
@@ -200,7 +201,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
|
||||
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields:
|
||||
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date,
|
||||
import date, edit date, name, rating or size. The justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
so they stay smooth whether you have forty photos or forty thousand.
|
||||
|
||||
<table>
|
||||
@@ -233,11 +234,11 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
- **91 engine commands** and **48 UI commands.** List them all with `engine.commands`; read every slider's range,
|
||||
default and current value with `develop.controls`.
|
||||
- **MCP server.** `lightcraft-cli mcp` gives Claude (or any MCP client) ~100 tools: import, query, develop, mask,
|
||||
render (returned as an image), export. It runs headless, or attached to the running app with screenshots, clicks
|
||||
and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
- **Command registry.** `engine.commands` lists the available commands; `develop.controls` lists every slider's
|
||||
range, default and current value.
|
||||
- **MCP server.** `lightcraft-cli mcp` exposes the command registry to Claude (or any MCP client), alongside
|
||||
helpers for import, query, develop, mask, render (returned as an image) and export. It runs headless, or attached
|
||||
to the running app with screenshots, clicks and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
```sh
|
||||
cargo build --release -p lightcraft-cli
|
||||
@@ -257,9 +258,10 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Fast, native, private
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD
|
||||
composites and JPEG XL open today.
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
|
||||
as in PhotoCraft).
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
@@ -287,8 +289,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
- **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.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
|
||||
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
|
||||
- **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;
|
||||
- **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).
|
||||
@@ -301,9 +303,9 @@ 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 | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) |
|
||||
| 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, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
|
||||
| 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 ⬜ |
|
||||
| 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 ⬜ |
|
||||
@@ -311,7 +313,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files 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)) |
|
||||
@@ -334,9 +336,69 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
|
||||
available when it starts (at most one per CPU; 4 if memory can't be read), so an
|
||||
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
|
||||
builds with fewer. Test threads follow, capped at 4. Full-debuginfo linkers, one
|
||||
per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit
|
||||
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
|
||||
override these defaults. Ordinary development commands keep their own settings.
|
||||
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
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).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) 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
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
|
||||
Folder** shows it in the file manager). Each start moves the
|
||||
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
|
||||
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
|
||||
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
|
||||
at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and
|
||||
errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces
|
||||
the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a
|
||||
directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there
|
||||
too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`.
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
**Nix** builds the desktop app and `lightcraft-cli` (the Nix build always includes the craft-fonts input, so Japanese
|
||||
text has glyphs):
|
||||
|
||||
```sh
|
||||
nix run github:storytold/lightcraft # the desktop app
|
||||
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
|
||||
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
|
||||
```
|
||||
|
||||
In a flake configuration (NixOS, home-manager, nix-darwin):
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.lightcraft.url = "github:storytold/lightcraft";
|
||||
# optional: build against your own nixpkgs instead of the one LightCraft pins
|
||||
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# then, in a NixOS or home-manager module (where `inputs` is in scope):
|
||||
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
|
||||
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
|
||||
```
|
||||
|
||||
`nix build` installs the same desktop file, hicolor icons and AppStream metadata as the .deb/.rpm, and runs
|
||||
`cargo test --workspace` as its check phase (skip it with `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
|
||||
|
||||
**Keyboard:** <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
|
||||
@@ -356,7 +418,8 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
|
||||
work from public specs and black-box observation.
|
||||
- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
|
||||
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
|
||||
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
|
||||
(one task id per commit).
|
||||
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`, `panic!` or
|
||||
@@ -367,6 +430,53 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
|
||||
|
||||
<br>
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
|
||||
| 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.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `lightcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `lightcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `lightcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
<br>
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
LightCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
@@ -379,7 +489,7 @@ stand on its own.
|
||||
| <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** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](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** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](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** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <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** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](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** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](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** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -420,7 +530,9 @@ with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTIO
|
||||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Inter (SIL OFL 1.1). All icons are original.
|
||||
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
|
||||
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
@@ -433,3 +545,7 @@ Forks and modified versions must remove them.
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
+70
-36
@@ -6,11 +6,11 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
|
||||
*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.*
|
||||
|
||||
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
|
||||
**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.
|
||||
|
||||
@@ -22,32 +22,35 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | 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 | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. 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 | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **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; no localisation; accessibility partial; headless UI tests time out under machine load |
|
||||
| **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 |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
|
||||
| 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% | No segmentation or AI denoise models |
|
||||
| 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 |
|
||||
|
||||
## Where we're going
|
||||
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
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. Raises colour quality for every non-DNG raw at once.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
|
||||
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
|
||||
@@ -56,7 +59,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
|
||||
(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; then pure-Rust inference. Unblocks M12 and Enhance.
|
||||
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.
|
||||
|
||||
@@ -64,6 +69,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
@@ -77,28 +86,28 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| 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, Raw Details, Super Resolution ⬜) |
|
||||
| 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 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
|
||||
| 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 ✅; accessibility, localisation ⬜) |
|
||||
| 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-05; effort estimate from 2026-10-02)
|
||||
## 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 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
| 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% |
|
||||
|
||||
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.
|
||||
@@ -115,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| 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 |
|
||||
@@ -159,42 +168,67 @@ The milestone estimates in the table above were made before work started and are
|
||||
|
||||
## 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), CR2, ARW (uncompressed, ARW2, LJ92), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
|
||||
X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
|
||||
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
|
||||
it can't decode yet.
|
||||
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).
|
||||
- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
|
||||
blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
|
||||
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**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/Panasonic equivalents) and our
|
||||
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.
|
||||
|
||||
## Japanese interface and text watermarks
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English/Japanese interface language is persisted in UI state. Core menus have Japanese
|
||||
translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled
|
||||
under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept
|
||||
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
|
||||
|
||||
@@ -25,7 +25,14 @@ serde_json = { workspace = true }
|
||||
dhat = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["denoise"]
|
||||
denoise = ["lightcraft-engine/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
|
||||
# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts
|
||||
# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json
|
||||
# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work.
|
||||
dhat-heap = ["dep:dhat"]
|
||||
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
+191
-43
@@ -9,6 +9,7 @@
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -70,6 +71,8 @@ USAGE:
|
||||
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
|
||||
Replies go to stdout. `ui.screenshot` without a path writes -o (then
|
||||
OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders.
|
||||
A failed request (\"ok\": false) does not stop the script, but the exit
|
||||
status is non-zero when any request failed.
|
||||
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
|
||||
--size WxH window size in points (default 1600x1000)
|
||||
--scale S pixels per point (default 1)
|
||||
@@ -84,6 +87,12 @@ USAGE:
|
||||
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
|
||||
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…
|
||||
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
|
||||
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
|
||||
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
|
||||
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
|
||||
take their colour from the profile and only their tone from their own JPEG.
|
||||
lightcraft-cli --version | --help
|
||||
";
|
||||
|
||||
@@ -93,6 +102,14 @@ static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr:
|
||||
/// stdout carries command results / MCP protocol messages only.
|
||||
fn library_warnings(session: &mut Session, who: &str) {
|
||||
for w in session.take_library_warnings() {
|
||||
eprintln!("{who}: warning: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
@@ -115,6 +132,9 @@ fn main() -> ExitCode {
|
||||
dhat::Profiler::builder().file_name(file).build()
|
||||
};
|
||||
alloc_release::install();
|
||||
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
|
||||
// or RUST_LOG picks another level (#168).
|
||||
lightcraft_engine::logging::install("lightcraft-cli");
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => run(&args[1..]),
|
||||
@@ -125,6 +145,7 @@ fn main() -> ExitCode {
|
||||
Some("merge") => merge(&args[1..]),
|
||||
Some("synth-merge") => synth_merge(&args[1..]),
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("calibrate") => calibrate(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -144,6 +165,115 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
|
||||
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
|
||||
let is_raw =
|
||||
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
|
||||
if path.is_file() {
|
||||
if is_raw(path) {
|
||||
out.push(path.to_path_buf());
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(path) else { return };
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with(['.', '@']) {
|
||||
continue;
|
||||
}
|
||||
raw_files(&entry.path(), out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn calibrate(args: &[String]) -> Result<(), String> {
|
||||
let mut max = 300usize;
|
||||
let mut out: Option<std::path::PathBuf> = None;
|
||||
let mut inputs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--max" => max = take_value(args, &mut i, "--max")?.parse().map_err(|_| "--max needs a number".to_string())?,
|
||||
"--out" => out = Some(take_value(args, &mut i, "--out")?.into()),
|
||||
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
|
||||
f => inputs.push(f.to_string()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if inputs.is_empty() {
|
||||
return Err("calibrate needs folders or raw files".into());
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
for input in &inputs {
|
||||
raw_files(Path::new(input), &mut files, 0);
|
||||
}
|
||||
files.sort();
|
||||
files.dedup();
|
||||
let found = files.len();
|
||||
if max > 0 && files.len() > max {
|
||||
// spread over all folders (dates, scenes) rather than the first N
|
||||
files = (0..max).filter_map(|k| files.get(k * found / max).cloned()).collect();
|
||||
}
|
||||
eprintln!("calibrate: {} of {found} raw files", files.len());
|
||||
let dir = out.or_else(lightcraft_engine::camera_profiles::dir).ok_or("no profiles folder: pass --out DIR")?;
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6);
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let done = std::sync::atomic::AtomicUsize::new(0);
|
||||
let pools: Vec<lightcraft_engine::camera_profiles::Pool> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
|
||||
while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes));
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
match result {
|
||||
Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()),
|
||||
Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()),
|
||||
Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()),
|
||||
}
|
||||
}
|
||||
pool
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect()
|
||||
});
|
||||
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
|
||||
for p in pools {
|
||||
pool.merge(p);
|
||||
}
|
||||
let mut written = 0;
|
||||
for result in pool.fit(5) {
|
||||
match result {
|
||||
Ok(profile) => {
|
||||
let path = lightcraft_engine::camera_profiles::save(&profile, &dir)?;
|
||||
println!(
|
||||
"{}: {} photos, {} colour pairs, hue/saturation table {} → {}",
|
||||
profile.model,
|
||||
profile.files,
|
||||
profile.samples,
|
||||
profile.hue_sat.is_some(),
|
||||
path.display()
|
||||
);
|
||||
written += 1;
|
||||
}
|
||||
Err(e) => eprintln!("calibrate: {e}"),
|
||||
}
|
||||
}
|
||||
for (model, n) in pool.files() {
|
||||
if n < 5 {
|
||||
eprintln!("calibrate: {model}: only {n} usable photo(s), at least 5 needed");
|
||||
}
|
||||
}
|
||||
if written == 0 {
|
||||
return Err("no profile written".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut demo = false;
|
||||
@@ -195,6 +325,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
library_warnings(&mut h.session, "lightcraft-cli mcp");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -211,7 +342,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
|
||||
let mut server = Server::new(backend).with_command_tools(!compact);
|
||||
let stdin = std::io::stdin();
|
||||
server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
|
||||
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn merge(args: &[String]) -> Result<(), String> {
|
||||
@@ -260,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models();
|
||||
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();
|
||||
@@ -416,39 +547,40 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
if steps.is_empty() {
|
||||
return Err("run: no command given (see `lightcraft-cli commands`)".into());
|
||||
}
|
||||
let mut backend: Box<dyn Backend> = match connect {
|
||||
Some(addr) => {
|
||||
if demo || library.is_some() || !imports.is_empty() {
|
||||
return Err("--demo, --library and --import apply to headless mode only".into());
|
||||
}
|
||||
Box::new(
|
||||
Remote::connect(&addr)
|
||||
.map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
None => Headless::default(),
|
||||
};
|
||||
if !imports.is_empty() {
|
||||
let paths = expand_paths(&imports);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
// what follows acts on the imported photos (already-known files are reported as duplicates)
|
||||
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
|
||||
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
|
||||
if let Some(first) = ids.first().cloned() {
|
||||
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
|
||||
let mut backend: Box<dyn Backend> =
|
||||
match connect {
|
||||
Some(addr) => {
|
||||
if demo || library.is_some() || !imports.is_empty() {
|
||||
return Err("--demo, --library and --import apply to headless mode only".into());
|
||||
}
|
||||
Box::new(Remote::connect(&addr).map_err(|e| {
|
||||
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
|
||||
})?)
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
None => {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
library_warnings(&mut h.session, "lightcraft-cli");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
None => Headless::default(),
|
||||
};
|
||||
if !imports.is_empty() {
|
||||
let paths = expand_paths(&imports);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
// what follows acts on the imported photos (already-known files are reported as duplicates)
|
||||
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
|
||||
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
|
||||
if let Some(first) = ids.first().cloned() {
|
||||
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut failed = 0;
|
||||
for s in &steps {
|
||||
@@ -474,6 +606,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
|
||||
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
|
||||
}
|
||||
|
||||
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
|
||||
/// no port, so a hint is never built from a guess.
|
||||
fn connect_port(addr: &str) -> &str {
|
||||
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut output = None;
|
||||
@@ -514,7 +652,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();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models();
|
||||
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"))?;
|
||||
@@ -537,7 +675,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
for (k, v) in opts {
|
||||
p[k] = v;
|
||||
}
|
||||
let mut o = ExportOptions::from_json(&p);
|
||||
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
if p.get("format").is_none() {
|
||||
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
@@ -590,19 +728,19 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
if script.is_none() && output.is_none() {
|
||||
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
|
||||
}
|
||||
if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
|
||||
return Err("snapshot: bad --size/--scale".into());
|
||||
}
|
||||
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
|
||||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().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(),
|
||||
None => Session::new().with_fs(),
|
||||
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(),
|
||||
};
|
||||
// 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())?;
|
||||
if !files.is_empty() {
|
||||
let ti = Instant::now();
|
||||
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
|
||||
@@ -617,7 +755,11 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
// Snapshot sessions promise no GPU: disable photo compute as well as the compositor
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
@@ -633,6 +775,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string())
|
||||
};
|
||||
let mut wrote_output = false;
|
||||
// Issue #166: a scripted request that fails still gets its reply printed and the run carries
|
||||
// on (like `run --keep-going`), but the exit status reports it — CI judges by exit status.
|
||||
let mut failed = 0usize;
|
||||
if let Some(script) = &script {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
@@ -665,6 +810,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
_ => h.request(&method, params, timeout),
|
||||
};
|
||||
if reply["ok"] != true {
|
||||
failed += 1;
|
||||
}
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
// wall time of the request (incl. the frames it ran), and since the start
|
||||
@@ -701,7 +849,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
|
||||
Ok(())
|
||||
if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn commands(args: &[String]) -> Result<(), String> {
|
||||
|
||||
@@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) {
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrate_reads_raf_inputs_and_reports_bad_files() {
|
||||
let input = tmp("invalid.RAF");
|
||||
std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap();
|
||||
let out = tmp("raf-profiles");
|
||||
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}");
|
||||
assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}");
|
||||
assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
|
||||
}
|
||||
|
||||
fn mean(path: &std::path::Path) -> f64 {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
|
||||
let img = d.to_srgb8();
|
||||
@@ -154,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"# comment\n{}\n{}\n{}\n{}\n",
|
||||
"# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
// (settled: under load a grid shot taken before its thumbnails is darker than the
|
||||
// dimmed export dialog over them, and the comparison below fails)
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot"}),
|
||||
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
|
||||
),
|
||||
)
|
||||
@@ -168,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4);
|
||||
assert_eq!(replies.len(), 6);
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
for (p, dimmed) in [(&a, false), (&b, true)] {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
|
||||
@@ -183,6 +200,127 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
assert_eq!((d.width, d.height), (640, 480));
|
||||
}
|
||||
|
||||
/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is
|
||||
/// still printed, the later lines still run, and the final `-o` screenshot is still written.
|
||||
#[test]
|
||||
fn snapshot_script_failure_exits_non_zero() {
|
||||
let script = tmp("snap-fail.jsonl");
|
||||
let out = tmp("snap-fail.png");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, …
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let o = Command::new(BIN)
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}");
|
||||
assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}");
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}");
|
||||
assert_eq!(replies[1]["ok"], true, "{replies:?}");
|
||||
// continue-and-report: the final screenshot is still written
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
#[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();
|
||||
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)
|
||||
.args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert!(replies[1]["result"]["pixelsPerPoint"].as_f64().unwrap() > scale.parse::<f64>().unwrap());
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless snapshots default to CPU photo rendering as well as CPU UI rasterization.
|
||||
#[test]
|
||||
fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
|
||||
let script = tmp("default-cpu.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})),
|
||||
)
|
||||
.unwrap();
|
||||
let image = tmp("default-cpu.png");
|
||||
let output = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
let gpu = &replies[0]["result"];
|
||||
assert_eq!(gpu["enabled"], false, "{gpu}");
|
||||
assert_eq!(gpu["available"], false, "{gpu}");
|
||||
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (480, 320));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
fn snapshot_starts_without_a_gpu() {
|
||||
let script = tmp("nogpu.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"{}\n{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
json!({"method": "engine.execute", "params": {"command": "app.gpu"}}),
|
||||
json!({"method": "ui.screenshot"}),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] {
|
||||
let out = tmp(&format!("nogpu-{var}.png"));
|
||||
let o = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env(var, value)
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{var}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
let gpu = &replies[1]["result"];
|
||||
assert_eq!(gpu["available"], false, "{var}: {gpu}");
|
||||
assert_eq!(gpu["adapter"], Value::Null, "{var}: no device was created: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(reason)), "{var}: {gpu}");
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (480, 320));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {
|
||||
let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
if let Some(text) = stdin {
|
||||
@@ -256,7 +394,9 @@ fn devices_are_listed_and_imported_from() {
|
||||
assert_eq!(dev["name"], "CARD", "{line}");
|
||||
// the import review of its DCIM folder finds the photo
|
||||
let dcim = dev["path"].as_str().unwrap().to_string();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap();
|
||||
// JSON-escaped: a Windows path has backslashes
|
||||
let paths = serde_json::to_string(&[&dcim]).unwrap();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
@@ -288,3 +428,115 @@ fn a_library_open_in_another_process_is_refused() {
|
||||
assert_eq!(lines[0]["ok"], true);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port
|
||||
/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
|
||||
#[test]
|
||||
fn connect_failure_hint_names_the_attempted_port() {
|
||||
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
|
||||
assert!(!ok);
|
||||
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
|
||||
assert!(!err.contains("--control 7980"), "{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]
|
||||
fn warnings_are_logged_on_stderr() {
|
||||
let input = tmp("log-in.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("log-out.jpg");
|
||||
let run = |level: &str| {
|
||||
let o = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.env("LIGHTCRAFT_GPU_BACKEND", "bogus")
|
||||
.env("RUST_LOG", level)
|
||||
.env_remove("LIGHTCRAFT_LOG")
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(o.status.success(), "{stderr}");
|
||||
stderr
|
||||
};
|
||||
let warn = run("warn");
|
||||
assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}");
|
||||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
for (index, (size, scale)) in
|
||||
[("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
|
||||
{
|
||||
let image = tmp(&format!("invalid-viewport-{index}.png"));
|
||||
let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!image.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_fractional_scale_rounding() {
|
||||
let image = tmp("fractional-scale-rounding.png");
|
||||
let output =
|
||||
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (311, 180));
|
||||
}
|
||||
|
||||
/// Snapshot's CPU setting must never replace the desktop preference, including with --library.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
let dir = tmp("snapshot-desktop-preferences");
|
||||
let library = dir.join("library");
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(&library, true).unwrap();
|
||||
drop(session);
|
||||
let config = dir.join("config");
|
||||
let app_config = config.join("lightcraft");
|
||||
std::fs::create_dir_all(&app_config).unwrap();
|
||||
let ui_path = app_config.join("ui.json");
|
||||
let mut ui = lightcraft_ui_egui::UiState::default();
|
||||
ui.settings.gpu = true;
|
||||
let saved = serde_json::to_vec_pretty(&ui).unwrap();
|
||||
std::fs::write(&ui_path, &saved).unwrap();
|
||||
let script = dir.join("preferences.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
for gpu_environment in [None, Some("1")] {
|
||||
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
|
||||
if let Some(value) = gpu_environment {
|
||||
cmd.env("LIGHTCRAFT_GPU", value);
|
||||
}
|
||||
let output = cmd
|
||||
.args(["snapshot", "--library"])
|
||||
.arg(&library)
|
||||
.args(["--script"])
|
||||
.arg(&script)
|
||||
.args(["-o"])
|
||||
.arg(&image)
|
||||
.args(["--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert_eq!(replies[0]["result"]["enabled"], false);
|
||||
assert_eq!(replies[0]["result"]["available"], false);
|
||||
assert_eq!(replies[0]["result"]["adapter"], Value::Null);
|
||||
assert_eq!(replies[1]["result"]["ui"]["settings"]["gpu"], false);
|
||||
assert_eq!(std::fs::read(&ui_path).unwrap(), saved, "snapshot changed the desktop preferences");
|
||||
assert!(!app_config.join("ui.json.tmp").exists());
|
||||
assert!(image.exists());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow"] }
|
||||
@@ -70,3 +71,9 @@ web-sys = { workspace = true, features = [
|
||||
"WorkerType",
|
||||
"WritableStream",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it. On wasm32 panics abort, so
|
||||
# heic-rs's `catch_unwind` guard does not apply there.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
@@ -24,13 +24,21 @@
|
||||
<script type="module">
|
||||
import init, { start, command } from "./lightcraft_web.js";
|
||||
(async () => {
|
||||
// The Chinese font is a separate file: embedding it would exceed the host's WASM size limit.
|
||||
// Font-free development builds can still run, with the same missing-glyph warning as before.
|
||||
const chineseFont = fetch("./lightcraft_zh_hans.otf")
|
||||
.then(async response => response.ok ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array())
|
||||
.catch(() => new Uint8Array());
|
||||
// Compile once; the render workers (worker.js) instantiate the same compiled module.
|
||||
const module = await WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm"));
|
||||
const [module, font] = await Promise.all([
|
||||
WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm")),
|
||||
chineseFont,
|
||||
]);
|
||||
globalThis.lightcraftModule = module;
|
||||
await init({ module_or_path: module });
|
||||
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
|
||||
globalThis.lightcraft = { command };
|
||||
start();
|
||||
start(font);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
const el = document.getElementById("lightcraft_loading");
|
||||
|
||||
@@ -232,6 +232,18 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is there a file at `path`?
|
||||
pub async fn exists(&self, path: &str) -> Result<bool, String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => Ok(Self::opfs_file(root, path, false).await?.is_some()),
|
||||
Backend::Idb(db) => {
|
||||
let (_tx, store) = Self::idb_store(db, false)?;
|
||||
let n = idb_req(&store.count_with_key(&path.into()).map_err(err)?).await?;
|
||||
Ok(n.as_f64().is_some_and(|n| n > 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, path: &str) -> Result<(), String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => {
|
||||
@@ -309,14 +321,25 @@ async fn dir_keys(dir: &FileSystemDirectoryHandle) -> Result<Vec<String>, String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ask the browser not to evict our storage under pressure (best effort; fire and forget).
|
||||
pub fn request_persistence() {
|
||||
if let Some(sm) = storage_manager()
|
||||
&& let Ok(p) = sm.persist()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let granted = JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy());
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted (best effort)" });
|
||||
});
|
||||
}
|
||||
/// Ask the browser not to evict our storage under pressure; `on_result(granted)` once known
|
||||
/// (`false` also when the browser has no storage manager).
|
||||
pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) {
|
||||
let Some(sm) = storage_manager() else {
|
||||
on_result(false);
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// already persistent: nothing to ask
|
||||
let already = match sm.persisted() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
let granted = already
|
||||
|| match sm.persist() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" });
|
||||
on_result(granted);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Library backups for the browser build: a plain zip (entries stored, not compressed — photos
|
||||
//! don't shrink) of the library files and every stored original, built one entry at a time so
|
||||
//! only one file is in wasm memory at once, and read back the same way for a restore.
|
||||
//!
|
||||
//! Layout of a backup:
|
||||
//! - `library/catalog.snap`, `library/catalog.log`, `library/presets.json`, … (see
|
||||
//! [`crate::files::LIBRARY_FILES`]);
|
||||
//! - `originals/<content hash>/<file name>`: each imported photo's original bytes, under its own
|
||||
//! name so an unzipped backup is browsable;
|
||||
//! - `README.txt`.
|
||||
//!
|
||||
//! Restoring never deletes anything: the library files go to a new library folder in browser
|
||||
//! storage, originals that aren't stored yet are added (they're named by content, so existing
|
||||
//! ones are identical; a restored one must match its content hash, see [`verify_original`]),
|
||||
//! and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to the restored library.
|
||||
//! The library that was there before stays in storage.
|
||||
//!
|
||||
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
|
||||
|
||||
/// Storage key of the file naming the library folder in use (absent: `library`).
|
||||
pub const ACTIVE_LIBRARY: &str = "active-library";
|
||||
|
||||
/// The default library folder.
|
||||
pub const DEFAULT_LIBRARY_DIR: &str = "library";
|
||||
|
||||
/// Largest backup (offsets are 32-bit without zip64).
|
||||
pub const MAX_BYTES: u64 = 0xFFFF_FFFF - (64 << 20);
|
||||
|
||||
const LOCAL_SIG: u32 = 0x0403_4b50;
|
||||
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
||||
const END_SIG: u32 = 0x0605_4b50;
|
||||
/// General-purpose flag bit 11: names are UTF-8.
|
||||
const UTF8: u16 = 0x0800;
|
||||
/// 1980-01-01 00:00 in MS-DOS format (date, time).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
|
||||
/// The readme inside every backup.
|
||||
pub const README: &str = "LightCraft library backup (browser version)\n\n\
|
||||
library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\
|
||||
originals/ every imported photo, as originals/<content hash>/<file name>\n\n\
|
||||
Restore it in LightCraft for the web with File > Restore Library from Backup...\n\
|
||||
The photos in originals/ are the untouched files you imported; edits live in the catalog.\n";
|
||||
|
||||
/// Is `name` a safe library folder name (`library` or `library-<letters, digits, ->`)?
|
||||
pub fn valid_library_dir(name: &str) -> bool {
|
||||
name == DEFAULT_LIBRARY_DIR
|
||||
|| name.strip_prefix("library-").is_some_and(|s| !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
}
|
||||
|
||||
/// Is `h` a plausible content hash (used as a storage key segment)?
|
||||
pub fn valid_hash(h: &str) -> bool {
|
||||
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Zip entry name for an original.
|
||||
pub fn original_entry(hash: &str, file_name: &str) -> String {
|
||||
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
|
||||
let name = if name.is_empty() || name == "." || name == ".." { "original".to_string() } else { name };
|
||||
format!("originals/{hash}/{name}")
|
||||
}
|
||||
|
||||
/// Where a backup entry goes when restoring into library folder `dir`: `Some(storage key)`, or
|
||||
/// `None` to skip it (README, thumbnails, anything unknown or unsafe).
|
||||
pub fn restore_key(entry: &str, dir: &str) -> Option<String> {
|
||||
if let Some(name) = entry.strip_prefix("library/") {
|
||||
return crate::files::LIBRARY_FILES.contains(&name).then(|| format!("{dir}/{name}"));
|
||||
}
|
||||
let rest = entry.strip_prefix("originals/")?;
|
||||
let (hash, _) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
valid_hash(hash).then(|| format!("originals/{hash}"))
|
||||
}
|
||||
|
||||
fn u16le(v: &mut Vec<u8>, x: u16) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
fn u32le(v: &mut Vec<u8>, x: u32) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
struct Central {
|
||||
name: String,
|
||||
crc: u32,
|
||||
size: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
/// Writes a stored (uncompressed) zip piece by piece: [`ZipWriter::entry`] returns the local
|
||||
/// header to emit before the entry's bytes, [`ZipWriter::finish`] the central directory.
|
||||
#[derive(Default)]
|
||||
pub struct ZipWriter {
|
||||
entries: Vec<Central>,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
impl ZipWriter {
|
||||
/// The local header for `name` holding `data` (emit it, then `data`).
|
||||
pub fn entry(&mut self, name: &str, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let size = u32::try_from(data.len()).map_err(|_| format!("{name}: larger than 4 GB"))?;
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| format!("{name}: name too long"))?;
|
||||
if self.entries.len() >= usize::from(u16::MAX) {
|
||||
return Err("too many files for one backup".into());
|
||||
}
|
||||
let header_len = 30 + u64::from(name_len);
|
||||
if self.offset + header_len + u64::from(size) > MAX_BYTES {
|
||||
return Err("the library is too large for one backup file (4 GB)".into());
|
||||
}
|
||||
let offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let crc = crc32fast::hash(data);
|
||||
let mut h = Vec::with_capacity(header_len as usize);
|
||||
u32le(&mut h, LOCAL_SIG);
|
||||
u16le(&mut h, 20); // version needed
|
||||
u16le(&mut h, UTF8);
|
||||
u16le(&mut h, 0); // stored
|
||||
u16le(&mut h, 0); // time
|
||||
u16le(&mut h, DOS_DATE);
|
||||
u32le(&mut h, crc);
|
||||
u32le(&mut h, size);
|
||||
u32le(&mut h, size);
|
||||
u16le(&mut h, name_len);
|
||||
u16le(&mut h, 0); // extra
|
||||
h.extend_from_slice(name.as_bytes());
|
||||
self.offset += header_len + u64::from(size);
|
||||
self.entries.push(Central { name: name.to_string(), crc, size, offset });
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The central directory and end record (emit last).
|
||||
pub fn finish(self) -> Result<Vec<u8>, String> {
|
||||
let mut cd = Vec::new();
|
||||
for e in &self.entries {
|
||||
u32le(&mut cd, CENTRAL_SIG);
|
||||
u16le(&mut cd, 20); // made by
|
||||
u16le(&mut cd, 20); // needed
|
||||
u16le(&mut cd, UTF8);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, DOS_DATE);
|
||||
u32le(&mut cd, e.crc);
|
||||
u32le(&mut cd, e.size);
|
||||
u32le(&mut cd, e.size);
|
||||
u16le(&mut cd, e.name.len() as u16);
|
||||
u16le(&mut cd, 0); // extra
|
||||
u16le(&mut cd, 0); // comment
|
||||
u16le(&mut cd, 0); // disk
|
||||
u16le(&mut cd, 0); // internal attrs
|
||||
u32le(&mut cd, 0); // external attrs
|
||||
u32le(&mut cd, e.offset);
|
||||
cd.extend_from_slice(e.name.as_bytes());
|
||||
}
|
||||
let cd_offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let cd_size = u32::try_from(cd.len()).map_err(|_| "backup too large".to_string())?;
|
||||
let n = self.entries.len() as u16;
|
||||
let mut out = cd;
|
||||
u32le(&mut out, END_SIG);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, n);
|
||||
u16le(&mut out, n);
|
||||
u32le(&mut out, cd_size);
|
||||
u32le(&mut out, cd_offset);
|
||||
u16le(&mut out, 0); // comment
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a zip's central directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ZipEntry {
|
||||
pub name: String,
|
||||
pub crc: u32,
|
||||
pub size: u64,
|
||||
/// Offset of the entry's local header.
|
||||
pub header: u64,
|
||||
/// 0 = stored (the only method a restore reads).
|
||||
pub method: u16,
|
||||
}
|
||||
|
||||
fn rd16(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?))
|
||||
}
|
||||
fn rd32(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// How many bytes from the end of a zip to read to find its end record.
|
||||
pub fn tail_len(file_len: u64) -> u64 {
|
||||
file_len.min(22 + 65_535)
|
||||
}
|
||||
|
||||
/// The central directory's (offset, size) from the last [`tail_len`] bytes of a zip.
|
||||
pub fn find_central(tail: &[u8]) -> Result<(u64, u64), String> {
|
||||
let at = (0..tail.len().saturating_sub(21)).rev().find(|&i| rd32(tail, i) == Some(END_SIG)).ok_or("not a zip file (no end record)")?;
|
||||
let size = rd32(tail, at + 12).ok_or("truncated zip")?;
|
||||
let offset = rd32(tail, at + 16).ok_or("truncated zip")?;
|
||||
if size == u32::MAX || offset == u32::MAX {
|
||||
return Err("zip64 backups are not supported".into());
|
||||
}
|
||||
Ok((u64::from(offset), u64::from(size)))
|
||||
}
|
||||
|
||||
/// Parse a central directory.
|
||||
pub fn parse_central(cd: &[u8]) -> Result<Vec<ZipEntry>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 46 <= cd.len() {
|
||||
if rd32(cd, i) != Some(CENTRAL_SIG) {
|
||||
return Err("damaged zip directory".into());
|
||||
}
|
||||
let bad = || "damaged zip directory".to_string();
|
||||
let method = rd16(cd, i + 10).ok_or_else(bad)?;
|
||||
let crc = rd32(cd, i + 16).ok_or_else(bad)?;
|
||||
let size = rd32(cd, i + 24).ok_or_else(bad)?;
|
||||
let n = usize::from(rd16(cd, i + 28).ok_or_else(bad)?);
|
||||
let extra = usize::from(rd16(cd, i + 30).ok_or_else(bad)?);
|
||||
let comment = usize::from(rd16(cd, i + 32).ok_or_else(bad)?);
|
||||
let header = rd32(cd, i + 42).ok_or_else(bad)?;
|
||||
let name = cd.get(i + 46..i + 46 + n).ok_or_else(bad)?;
|
||||
out.push(ZipEntry { name: String::from_utf8_lossy(name).into_owned(), crc, size: u64::from(size), header: u64::from(header), method });
|
||||
i += 46 + n + extra + comment;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Offset of an entry's data from its local header's first 30 bytes.
|
||||
pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
|
||||
if rd32(local, 0) != Some(LOCAL_SIG) {
|
||||
return Err(format!("{}: damaged entry", entry.name));
|
||||
}
|
||||
let n = rd16(local, 26).ok_or("truncated entry")?;
|
||||
let extra = rd16(local, 28).ok_or("truncated entry")?;
|
||||
Ok(entry.header + 30 + u64::from(n) + u64::from(extra))
|
||||
}
|
||||
|
||||
/// Check an entry's bytes.
|
||||
pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
||||
if entry.method != 0 {
|
||||
return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name));
|
||||
}
|
||||
if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc {
|
||||
return Err(format!("{}: damaged (checksum mismatch)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check that an original's bytes are the photo its storage key names: originals are stored
|
||||
/// under their content hash, and a restore that kept bytes under another photo's hash would
|
||||
/// give that photo's catalog record a different image (the zip checksum can't tell).
|
||||
pub fn verify_original(entry: &ZipEntry, hash: &str, data: &[u8]) -> Result<(), String> {
|
||||
if crate::store::content_hash(data) != hash {
|
||||
return Err(format!("{}: the file doesn't match its content hash (damaged or altered backup)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name for the restored library's folder.
|
||||
pub fn restored_dir_name(now_ms: f64) -> String {
|
||||
format!("library-restored-{}", now_ms.max(0.0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut w = ZipWriter::default();
|
||||
let mut out = Vec::new();
|
||||
for (n, d) in files {
|
||||
out.extend(w.entry(n, d).unwrap());
|
||||
out.extend_from_slice(d);
|
||||
}
|
||||
out.extend(w.finish().unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_backups_read_back() {
|
||||
let files: [(&str, &[u8]); 3] =
|
||||
[("README.txt", README.as_bytes()), ("library/catalog.log", b"{\"seq\":1}\n"), ("originals/abc123/IMG 1.jpg", &[0, 1, 2, 3, 255])];
|
||||
let zip = build(&files);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
for (e, (name, data)) in entries.iter().zip(files) {
|
||||
assert_eq!(e.name, name);
|
||||
let start = data_offset(e, &zip[e.header as usize..e.header as usize + 30]).unwrap() as usize;
|
||||
let got = &zip[start..start + e.size as usize];
|
||||
assert_eq!(got, data);
|
||||
verify(e, got).unwrap();
|
||||
}
|
||||
// damage is caught
|
||||
let e = &entries[2];
|
||||
assert!(verify(e, &[0, 1, 2, 3, 254]).is_err());
|
||||
assert!(find_central(b"not a zip at all, just some text").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_must_match_their_content_hash() {
|
||||
let red: &[u8] = b"red corner photo";
|
||||
let blue: &[u8] = b"blue corner photo";
|
||||
let hash = crate::store::content_hash(red);
|
||||
let name = original_entry(&hash, "IMG 1.png");
|
||||
let key = restore_key(&name, "d").unwrap();
|
||||
let key_hash = key.strip_prefix("originals/").unwrap();
|
||||
assert_eq!(key_hash, hash);
|
||||
// the entry's own bytes pass; other bytes under the same name (valid zip CRC) don't
|
||||
let zip = build(&[(name.as_str(), blue)]);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
let entry = &entries[0];
|
||||
verify(entry, blue).unwrap();
|
||||
let err = verify_original(entry, key_hash, blue).unwrap_err();
|
||||
assert!(err.contains(&name), "{err}");
|
||||
verify_original(entry, key_hash, red).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keys_are_safe() {
|
||||
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
|
||||
assert_eq!(restore_key("library/../../x", "library-r"), None);
|
||||
assert_eq!(restore_key("library/evil.bin", "library-r"), None);
|
||||
assert_eq!(restore_key("originals/abc123/IMG.jpg", "d").as_deref(), Some("originals/abc123"));
|
||||
assert_eq!(restore_key("originals/../library/catalog.log", "d"), None);
|
||||
assert_eq!(restore_key("README.txt", "d"), None);
|
||||
assert_eq!(restore_key("thumbs/x.jpg", "d"), None);
|
||||
assert_eq!(original_entry("abc", "a/b\\c.jpg"), "originals/abc/a_b_c.jpg");
|
||||
assert_eq!(original_entry("abc", ".."), "originals/abc/original");
|
||||
assert!(valid_library_dir("library") && valid_library_dir("library-restored-17"));
|
||||
assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limits_are_errors_not_panics() {
|
||||
let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() };
|
||||
assert!(w.entry("x", &[0; 64]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ struct Mirror {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
/// Dirty files, least recently modified first.
|
||||
dirty: Vec<(String, Dirty)>,
|
||||
/// Why the last flush to browser storage failed (quota, eviction…), until one succeeds.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
@@ -138,6 +140,18 @@ impl Files {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the outcome of a flush to browser storage: `Some(why)` when it failed, `None` once
|
||||
/// one succeeds. While a flush is failing, the catalog [`Store`] refuses new writes, so the
|
||||
/// engine keeps the changes queued and reports them unsaved (`library.info` → `unsavedOps`,
|
||||
/// the top bar's warning) instead of pretending they were saved.
|
||||
pub fn set_error(&self, e: Option<String>) {
|
||||
self.lock().error = e;
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.lock().error.clone()
|
||||
}
|
||||
|
||||
/// A catalog [`Store`] over these files.
|
||||
pub fn store(&self) -> FilesStore {
|
||||
FilesStore(self.clone())
|
||||
@@ -147,19 +161,32 @@ impl Files {
|
||||
/// [`Store`] implementation over [`Files`].
|
||||
pub struct FilesStore(Files);
|
||||
|
||||
impl FilesStore {
|
||||
/// Writes fail while browser storage does (see [`Files::set_error`]).
|
||||
fn writable(&self) -> io::Result<()> {
|
||||
match self.0.error() {
|
||||
Some(e) => Err(io::Error::other(format!("browser storage: {e}"))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for FilesStore {
|
||||
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
Ok(self.0.get(name))
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.write(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.append(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.truncate(name, len as usize);
|
||||
Ok(())
|
||||
}
|
||||
@@ -224,6 +251,37 @@ mod tests {
|
||||
assert_eq!(names, ["catalog.snap", "catalog.log"]);
|
||||
}
|
||||
|
||||
/// A failing flush (quota exceeded, storage evicted) is not silent: commands report the change
|
||||
/// as unsaved and the engine keeps it queued; once storage works again it is written.
|
||||
#[test]
|
||||
fn flush_failures_show_as_unsaved_and_are_retried() {
|
||||
let mut disk = BTreeMap::new();
|
||||
let files = Files::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
flush(&files, &mut disk);
|
||||
files.set_error(Some("QuotaExceededError".into()));
|
||||
let r = s.execute("photo.rate", &json!({"rating": 5}));
|
||||
assert!(matches!(r, Err(lightcraft_engine::EngineError::NotSaved(_))), "{r:?}");
|
||||
let (ops, why) = s.unsaved().unwrap();
|
||||
assert!(ops > 0 && why.contains("QuotaExceeded"), "{why}");
|
||||
files.set_error(None);
|
||||
s.persist_if_dirty();
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_none(), "written once storage works again");
|
||||
flush(&files, &mut disk);
|
||||
let id = s.selection.active.unwrap();
|
||||
let files2 = Files::default();
|
||||
for (k, v) in &disk {
|
||||
files2.preload(k, v.clone());
|
||||
}
|
||||
let mut s2 = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files2.store()), files: Box::new(files2.store()), on_disk: false };
|
||||
s2.open_library_in(stores, true).unwrap();
|
||||
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_survives_reload_through_flushes() {
|
||||
let mut disk = BTreeMap::new();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod backup;
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
pub mod store;
|
||||
@@ -24,6 +25,8 @@ pub mod wire;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod backend;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod safety;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod workers;
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the
|
||||
//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message
|
||||
//! instead of a dead page after a panic, and backup / restore of the whole library
|
||||
//! ([`crate::backup`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use js_sys::{Reflect, Uint8Array};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
valid_hash, verify, verify_original,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
use crate::store::storage_key;
|
||||
|
||||
thread_local! {
|
||||
/// Messages for the user, shown by the app on its next frame.
|
||||
static NOTICES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Queue a message for the user (shown for several seconds; also logged).
|
||||
pub fn notice(text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
log::warn!("{text}");
|
||||
NOTICES.with(|n| n.borrow_mut().push(text));
|
||||
crate::web::request_repaint();
|
||||
}
|
||||
|
||||
/// Messages queued since the last call.
|
||||
pub fn take_notices() -> Vec<String> {
|
||||
NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut()))
|
||||
}
|
||||
|
||||
fn document() -> Option<web_sys::Document> {
|
||||
web_sys::window()?.document()
|
||||
}
|
||||
|
||||
/// Ask the user (the browser's own confirm box). `false` when it can't be shown.
|
||||
pub fn confirm(text: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cover the page with a message (the app can't continue, or must not start).
|
||||
pub fn show_blocking(text: &str) {
|
||||
let Some(doc) = document() else { return };
|
||||
let el = match doc.get_element_by_id("lightcraft_blocking") {
|
||||
Some(el) => el,
|
||||
None => {
|
||||
let Ok(el) = doc.create_element("div") else { return };
|
||||
el.set_id("lightcraft_blocking");
|
||||
let _ = el.set_attribute(
|
||||
"style",
|
||||
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
|
||||
background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line",
|
||||
);
|
||||
if let Some(body) = doc.body() {
|
||||
let _ = body.append_child(&el);
|
||||
}
|
||||
el
|
||||
}
|
||||
};
|
||||
el.set_text_content(Some(text));
|
||||
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
|
||||
loading.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a
|
||||
/// frozen canvas, and say that the stored library is intact.
|
||||
pub fn install_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown error".into(),
|
||||
};
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
log::error!("LightCraft panicked{at}: {msg}");
|
||||
show_blocking(&format!(
|
||||
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
|
||||
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
|
||||
));
|
||||
}));
|
||||
}
|
||||
|
||||
/// The name of the lock that keeps a library to one tab.
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks, or
|
||||
/// it refused the request).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
// a refused request never calls `cb`: settle with its error instead of waiting forever
|
||||
match request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
Ok(pending) => {
|
||||
let catch = Reflect::get(&pending, &"catch".into()).ok().and_then(|c| c.dyn_into::<js_sys::Function>().ok());
|
||||
if let Some(catch) = catch {
|
||||
let _ = catch.call1(&pending, &reject);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
}
|
||||
});
|
||||
match JsFuture::from(granted).await {
|
||||
Ok(v) => Some(v.is_truthy()),
|
||||
Err(e) => {
|
||||
log::warn!("Web Locks request failed, starting without the one-tab guard: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> {
|
||||
let e = |e: JsValue| format!("download failed: {e:?}");
|
||||
let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?;
|
||||
let doc = document().ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
// revoke once the download has started
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and
|
||||
/// every original in storage, built entry by entry into the browser's memory (outside the wasm
|
||||
/// heap) and downloaded. `names`: content hash → the photo's file name.
|
||||
pub async fn backup(files: Files, backend: Backend, names: HashMap<String, String>) -> Result<(usize, f64), String> {
|
||||
let parts = js_sys::Array::new();
|
||||
let mut zip = ZipWriter::default();
|
||||
let mut bytes = 0f64;
|
||||
let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> {
|
||||
let header = zip.entry(name, data)?;
|
||||
parts.push(&Uint8Array::from(&header[..]));
|
||||
parts.push(&Uint8Array::from(data));
|
||||
bytes += (header.len() + data.len()) as f64;
|
||||
Ok(())
|
||||
};
|
||||
add(&mut zip, "README.txt", README.as_bytes())?;
|
||||
for name in LIBRARY_FILES {
|
||||
if let Some(d) = files.get(name) {
|
||||
add(&mut zip, &format!("library/{name}"), &d)?;
|
||||
}
|
||||
}
|
||||
let mut originals = 0;
|
||||
for hash in backend.list("originals").await? {
|
||||
if !valid_hash(&hash) {
|
||||
continue;
|
||||
}
|
||||
match backend.read(&storage_key(&hash)).await {
|
||||
Ok(Some(data)) => {
|
||||
let name = names.get(&hash).map_or("original", String::as_str);
|
||||
add(&mut zip, &original_entry(&hash, name), &data)?;
|
||||
originals += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => return Err(format!("reading an original from browser storage: {e}")),
|
||||
}
|
||||
}
|
||||
let tail = zip.finish()?;
|
||||
parts.push(&Uint8Array::from(&tail[..]));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/zip");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
|
||||
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
|
||||
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
|
||||
Ok((originals, bytes + tail.len() as f64))
|
||||
}
|
||||
|
||||
/// Bytes `start..end` of a picked file.
|
||||
async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
|
||||
let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?;
|
||||
let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten),
|
||||
/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page.
|
||||
pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, String> {
|
||||
let len = file.size() as u64;
|
||||
let tail = read_range(&file, len - tail_len(len), len).await?;
|
||||
let (cd_offset, cd_size) = find_central(&tail)?;
|
||||
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
|
||||
let entries = parse_central(&cd)?;
|
||||
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
|
||||
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
|
||||
}
|
||||
let dir = restored_dir_name(js_sys::Date::now());
|
||||
let mut written = 0usize;
|
||||
for e in &entries {
|
||||
let Some(key) = restore_key(&e.name, &dir) else { continue };
|
||||
// originals are named by their content: one already stored is the same file
|
||||
if key.starts_with("originals/") && backend.exists(&key).await? {
|
||||
continue;
|
||||
}
|
||||
let local = read_range(&file, e.header, e.header + 30).await?;
|
||||
let start = data_offset(e, &local)?;
|
||||
let data = read_range(&file, start, start + e.size).await?;
|
||||
verify(e, &data)?;
|
||||
if let Some(hash) = key.strip_prefix("originals/") {
|
||||
verify_original(e, hash, &data)?;
|
||||
}
|
||||
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
|
||||
written += 1;
|
||||
}
|
||||
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
|
||||
log::info!("lightcraft: restored {written} files into {dir}");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
|
||||
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
|
||||
let ok = confirm(
|
||||
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
|
||||
The current library is not deleted: it stays in browser storage.",
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = document() else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".zip,application/zip");
|
||||
let inp = input.clone();
|
||||
let mut before_reload = Some(before_reload);
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
let Some(file) = inp.files().and_then(|f| f.get(0)) else { return };
|
||||
let backend = backend.clone();
|
||||
let before = before_reload.take();
|
||||
notice(format!("Restoring {}…", file.name()));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match restore(file, backend).await {
|
||||
Ok(_) => {
|
||||
if let Some(f) = before {
|
||||
f();
|
||||
}
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.location().reload();
|
||||
}
|
||||
}
|
||||
Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")),
|
||||
}
|
||||
});
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
on_change.forget();
|
||||
input.click();
|
||||
}
|
||||
+173
-27
@@ -15,16 +15,21 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir};
|
||||
use crate::bench::Bench;
|
||||
use crate::files::{Files, FlushOp, LIBRARY_FILES};
|
||||
use crate::safety::{self, notice};
|
||||
use crate::store::{Originals, content_hash, download_name, storage_key};
|
||||
use crate::wire::ThumbIndex;
|
||||
use crate::workers::{THUMB_INDEX, Workers};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
/// Where the library files live in browser storage.
|
||||
const LIBRARY_DIR: &str = "library";
|
||||
/// After a failed save, wait this long before trying again (ms).
|
||||
const SAVE_RETRY_MS: f64 = 2000.0;
|
||||
|
||||
/// How long a notice stays on screen (s).
|
||||
const NOTICE_SECS: f64 = 8.0;
|
||||
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
@@ -45,8 +50,18 @@ fn set_status(text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn library_key(name: &str) -> String {
|
||||
format!("{LIBRARY_DIR}/{name}")
|
||||
/// Storage key of library file `name` in library folder `dir`.
|
||||
fn library_key(dir: &str, name: &str) -> String {
|
||||
format!("{dir}/{name}")
|
||||
}
|
||||
|
||||
/// Repaint the app (notices from async tasks).
|
||||
pub(crate) fn request_repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// URL options (`?bench&store=idb&workers=2&reset`).
|
||||
@@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option<Backend>, name: String
|
||||
if let Some(b) = &backend
|
||||
&& let Err(e) = b.write(&storage_key(&hash), &bytes).await
|
||||
{
|
||||
// still importable for this session; it won't survive a reload
|
||||
log::error!("storing {name} in browser storage failed: {e}");
|
||||
// a photo whose bytes aren't stored would be gone after a reload: don't add it
|
||||
notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos."));
|
||||
return;
|
||||
}
|
||||
originals.added(&name, &hash, Arc::from(bytes));
|
||||
ctx.request_repaint();
|
||||
@@ -148,15 +164,54 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context) -> Services {
|
||||
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
picker: 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
|
||||
let names: std::collections::HashMap<String, String> = session
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let files = backup_files.clone();
|
||||
notice("Backing up the library… (the download starts when it's ready)");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match safety::backup(files, b, names).await {
|
||||
Ok((n, bytes)) => notice(format!(
|
||||
"Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
bytes / 1e6
|
||||
)),
|
||||
Err(e) => notice(format!("Backup failed: {e}")),
|
||||
}
|
||||
});
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
restore_library: Some(Box::new(move |_session: &mut Session| {
|
||||
let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
let frozen = frozen.clone();
|
||||
// once the backup is in place, stop saving the old library (the page reloads)
|
||||
safety::pick_and_restore(b, move || frozen.set(true));
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
pick_files: Some(Box::new(move || {
|
||||
open_picker(originals.clone(), backend.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
})),
|
||||
pick_denoise_model: None,
|
||||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
// Face models need a folder to live in; the web has none.
|
||||
pick_model_file: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
@@ -167,6 +222,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
reveal: None,
|
||||
log_file: None,
|
||||
open_with: None,
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
@@ -176,21 +232,29 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write dirty library files to storage, in order, until none are left.
|
||||
async fn flush(files: Files, backend: Backend, flushing: Rc<Cell<bool>>) {
|
||||
/// Write dirty library files to storage, in order, until none are left. A failure (quota,
|
||||
/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead
|
||||
/// of accepting them silently — and retried after [`SAVE_RETRY_MS`].
|
||||
async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bool>>, failed_at: Rc<Cell<f64>>) {
|
||||
'outer: loop {
|
||||
let ops = files.take_dirty();
|
||||
if ops.is_empty() {
|
||||
if files.error().is_some() {
|
||||
log::info!("lightcraft: saving works again");
|
||||
}
|
||||
files.set_error(None);
|
||||
break;
|
||||
}
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let r = match op {
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await,
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::error!("saving {} failed: {e}", op.name());
|
||||
files.failed(&ops[i..]);
|
||||
files.set_error(Some(e));
|
||||
failed_at.set(perf_now());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
@@ -204,6 +268,8 @@ struct Boot {
|
||||
backend: Option<Backend>,
|
||||
files: Files,
|
||||
index: ThumbIndex,
|
||||
/// The library folder in storage (`library`, or a restored backup's).
|
||||
lib_dir: String,
|
||||
}
|
||||
|
||||
async fn boot(opts: Options) -> Boot {
|
||||
@@ -221,15 +287,32 @@ async fn boot(opts: Options) -> Boot {
|
||||
};
|
||||
let files = Files::default();
|
||||
let mut index = ThumbIndex::default();
|
||||
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
// everything this site keeps in the browser, photos included: never without asking
|
||||
let ok = safety::confirm(
|
||||
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
|
||||
This can't be undone. Delete it?",
|
||||
);
|
||||
if ok {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
}
|
||||
} else {
|
||||
log::info!("lightcraft: ?reset declined; storage kept");
|
||||
}
|
||||
}
|
||||
// a restored backup lives in its own folder (see `backup`)
|
||||
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
|
||||
let d = String::from_utf8_lossy(&d).trim().to_string();
|
||||
if valid_library_dir(&d) {
|
||||
lib_dir = d;
|
||||
}
|
||||
}
|
||||
for name in LIBRARY_FILES {
|
||||
match b.read(&library_key(name)).await {
|
||||
match b.read(&library_key(&lib_dir, name)).await {
|
||||
Ok(Some(data)) => files.preload(name, data),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("reading {name}: {e}"),
|
||||
@@ -238,7 +321,14 @@ async fn boot(opts: Options) -> Boot {
|
||||
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
|
||||
index = ThumbIndex::from_json(&j);
|
||||
}
|
||||
crate::backend::request_persistence();
|
||||
crate::backend::request_persistence(|granted| {
|
||||
if !granted {
|
||||
notice(
|
||||
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
|
||||
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
|
||||
);
|
||||
}
|
||||
});
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
let b2 = b.clone();
|
||||
let known: std::collections::HashSet<String> = b
|
||||
@@ -266,7 +356,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
index.len(),
|
||||
index.total() as f64 / 1e6
|
||||
);
|
||||
Boot { opts, backend, files, index }
|
||||
Boot { opts, backend, files, index, lib_dir }
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
@@ -276,6 +366,11 @@ struct WebApp {
|
||||
backend: Option<Backend>,
|
||||
workers: Option<Workers>,
|
||||
flushing: Rc<Cell<bool>>,
|
||||
/// When the last save to storage failed (ms; 0 = never).
|
||||
flush_failed_at: Rc<Cell<f64>>,
|
||||
/// Saving stopped (a restored backup is about to replace this library: the page reloads).
|
||||
frozen: Rc<Cell<bool>>,
|
||||
lib_dir: String,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
last_save: f64,
|
||||
@@ -283,14 +378,14 @@ struct WebApp {
|
||||
}
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
let Boot { opts, backend, files, index } = boot;
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::new();
|
||||
originals.install(&mut session);
|
||||
let stores = LibraryStores {
|
||||
dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
catalog: Box::new(files.store()),
|
||||
files: Box::new(files.store()),
|
||||
on_disk: false,
|
||||
@@ -319,9 +414,17 @@ impl WebApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
if backend.is_some() {
|
||||
notice(
|
||||
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
|
||||
elsewhere and back up with File ▸ Back Up Library….",
|
||||
);
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone()));
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
|
||||
.with_chinese_font(chinese_font);
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
app.ui = ui;
|
||||
@@ -332,8 +435,18 @@ impl WebApp {
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let workers =
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone()));
|
||||
let cache = app.session.media.rendered.clone();
|
||||
let workers = (n > 0).then(|| {
|
||||
Workers::start(
|
||||
n,
|
||||
backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
backend.clone(),
|
||||
index,
|
||||
&cache,
|
||||
app.session.cache_bytes(),
|
||||
cc.egui_ctx.clone(),
|
||||
)
|
||||
});
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
@@ -347,6 +460,9 @@ impl WebApp {
|
||||
backend,
|
||||
workers,
|
||||
flushing: Rc::new(Cell::new(false)),
|
||||
flush_failed_at: Rc::new(Cell::new(0.0)),
|
||||
frozen,
|
||||
lib_dir,
|
||||
bench: opts.bench.then(|| Bench::new(origin)),
|
||||
first_frame_logged: false,
|
||||
last_save: 0.0,
|
||||
@@ -408,6 +524,9 @@ impl WebApp {
|
||||
|
||||
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
|
||||
fn save(&mut self) {
|
||||
if self.frozen.get() {
|
||||
return;
|
||||
}
|
||||
let now = perf_now();
|
||||
if now - self.last_save >= SAVE_EVERY_MS {
|
||||
self.last_save = now;
|
||||
@@ -429,12 +548,16 @@ impl WebApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
// after a failure, retry every couple of seconds rather than every frame
|
||||
let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS;
|
||||
if let Some(b) = &self.backend
|
||||
&& self.files.is_dirty()
|
||||
&& !self.flushing.get()
|
||||
&& !backing_off
|
||||
{
|
||||
self.flushing.set(true);
|
||||
wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone()));
|
||||
let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone());
|
||||
wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,8 +587,10 @@ impl WebApp {
|
||||
let (orig_n, orig_bytes) = self.originals.usage();
|
||||
json!({
|
||||
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
"library": self.lib_dir,
|
||||
"dirty": self.files.is_dirty(),
|
||||
"flushing": self.flushing.get(),
|
||||
"saveError": self.files.error(),
|
||||
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
|
||||
"originalsInMemory": orig_n,
|
||||
"originalBytesInMemory": orig_bytes,
|
||||
@@ -490,9 +615,18 @@ impl WebApp {
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS, None));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
if let Some(w) = &self.workers {
|
||||
// `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on
|
||||
// its own thread, so it gets the budget every frame and applies it on the next store.
|
||||
w.set_budget(self.app.session.cache_bytes());
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.save();
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
@@ -523,13 +657,25 @@ impl eframe::App for WebApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the app (called by `index.html` after instantiating the module).
|
||||
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
pub fn start(chinese_font: Vec<u8>) {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
if chinese_font.is_empty() {
|
||||
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
|
||||
}
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
|
||||
safety::show_blocking(
|
||||
"LightCraft is already open in another tab or window of this browser.\n\n\
|
||||
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
@@ -540,7 +686,7 @@ pub fn start() {
|
||||
};
|
||||
let boot = boot(opts).await;
|
||||
let runner = eframe::WebRunner::new();
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot, chinese_font))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
|
||||
+202
-14
@@ -11,14 +11,13 @@
|
||||
//! ([`ThumbIndex`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use lightcraft_engine::catalog::Source;
|
||||
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::Lru;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_preview::{Hash128, Lru, PreviewCache};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::hash_of_path;
|
||||
@@ -26,11 +25,17 @@ use crate::store::hash_of_path;
|
||||
/// One render request for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WireJob {
|
||||
#[serde(default)]
|
||||
pub cache_generation: u64,
|
||||
#[serde(default)]
|
||||
pub source_identity: Option<String>,
|
||||
pub level: SourceLevel,
|
||||
pub origin: Source,
|
||||
/// Decode the source at most this long.
|
||||
pub max_edge: usize,
|
||||
pub raw: bool,
|
||||
#[serde(default)]
|
||||
pub relative_wb: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
@@ -48,6 +53,9 @@ pub struct WireJob {
|
||||
/// The request's diagnostic overlay (`Overlay::to_parts`).
|
||||
#[serde(default)]
|
||||
pub overlay: (u8, f64),
|
||||
/// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view).
|
||||
#[serde(default)]
|
||||
pub window: Option<[usize; 4]>,
|
||||
}
|
||||
|
||||
impl WireJob {
|
||||
@@ -55,14 +63,17 @@ impl WireJob {
|
||||
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::Loaded(img) => img.width.max(img.height),
|
||||
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
cache_generation: job.cache_generation,
|
||||
source_identity: job.source_key.map(|key| key.to_string()),
|
||||
level: job.level,
|
||||
origin: job.origin.clone(),
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
relative_wb: job.info.relative_wb,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
@@ -75,11 +86,19 @@ impl WireJob {
|
||||
thumb_cached,
|
||||
stages: job.stages.is_some().then(|| view.to_string()),
|
||||
overlay: job.request.overlay.to_parts(),
|
||||
window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SourceInfo {
|
||||
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
|
||||
SourceInfo {
|
||||
raw: self.raw,
|
||||
as_shot_temp: self.as_shot_temp,
|
||||
as_shot_tint: self.as_shot_tint,
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(&self) -> RenderRequest {
|
||||
@@ -93,14 +112,15 @@ impl WireJob {
|
||||
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
proof: None,
|
||||
window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the decoded source this job needs.
|
||||
fn source_key(&self) -> String {
|
||||
match &self.origin {
|
||||
Source::File { path } => format!("{}:{path}", self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:demo:{scene}", self.max_edge),
|
||||
Source::File { path } => format!("{}:{:?}:{}:{path}", self.cache_generation, self.source_identity, self.max_edge),
|
||||
Source::Demo { scene } => format!("{}:{:?}:{}:demo:{scene}", self.cache_generation, self.source_identity, self.max_edge),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +139,7 @@ const WORKER_SOURCE_BYTES: usize = 192 << 20;
|
||||
|
||||
/// The synchronous part of a worker: decoded-source cache + rendering.
|
||||
pub struct WorkerCore {
|
||||
sources: Lru<String, Arc<Rgb32f>>,
|
||||
sources: Lru<String, DecodedSource>,
|
||||
media: MediaCache,
|
||||
stages: HashMap<String, Arc<StageCache>>,
|
||||
}
|
||||
@@ -150,21 +170,25 @@ impl WorkerCore {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let src = match (&job.origin, original) {
|
||||
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
|
||||
(Source::File { .. }, Some(bytes)) => {
|
||||
let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?;
|
||||
DecodedSource::new(Arc::new(image), Some(info))
|
||||
}
|
||||
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?,
|
||||
};
|
||||
let cost = src.width * src.height * 12 + 64;
|
||||
let cost = src.image.width * src.image.height * 12 + 64;
|
||||
self.sources.insert(key, src.clone(), cost);
|
||||
src
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -173,6 +197,8 @@ impl WorkerCore {
|
||||
/// pruned like the native disk cache (least recently used first, down to 80 % of the budget).
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct ThumbIndex {
|
||||
#[serde(default)]
|
||||
namespace: u64,
|
||||
/// hex key → (bytes, last use).
|
||||
entries: HashMap<String, (u64, u64)>,
|
||||
clock: u64,
|
||||
@@ -183,6 +209,24 @@ pub struct ThumbIndex {
|
||||
}
|
||||
|
||||
impl ThumbIndex {
|
||||
pub fn namespace(&self) -> u64 {
|
||||
self.namespace
|
||||
}
|
||||
|
||||
/// A persistent new namespace prevents late old workers restoring an invalidated disk hit.
|
||||
pub fn cache_key(&self, key: Hash128) -> String {
|
||||
if self.namespace == 0 {
|
||||
return key.to_string();
|
||||
}
|
||||
lightcraft_preview::Hasher128::new().str(&key.to_string()).str("cleared").u64(self.namespace).finish().to_string()
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) -> Vec<String> {
|
||||
self.namespace = self.namespace.wrapping_add(1);
|
||||
self.total = 0;
|
||||
self.dirty = true;
|
||||
self.entries.drain().map(|(key, _)| key).collect()
|
||||
}
|
||||
pub fn from_json(bytes: &[u8]) -> ThumbIndex {
|
||||
let mut i: ThumbIndex = serde_json::from_slice(bytes).unwrap_or_default();
|
||||
i.total = i.entries.values().map(|e| e.0).sum();
|
||||
@@ -260,11 +304,89 @@ impl ThumbIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which rendered-thumbnail cache, at which generation, the stored thumbnails ([`ThumbIndex`])
|
||||
/// belong to (main thread). Clearing the previews bumps the cache's generation, and opening
|
||||
/// another library replaces the cache: either makes every stored thumbnail obsolete.
|
||||
pub struct CacheWatch(Option<(Weak<PreviewCache>, u64)>);
|
||||
|
||||
impl CacheWatch {
|
||||
/// Watch `active`, the session's cache when the workers start: the persisted index belongs
|
||||
/// to it at its current generation. (Without this baseline, the first observation after a
|
||||
/// clear would only record the cleared generation and keep the obsolete thumbnails.)
|
||||
pub fn new(active: &Arc<PreviewCache>) -> CacheWatch {
|
||||
CacheWatch(Some((Arc::downgrade(active), active.generation())))
|
||||
}
|
||||
|
||||
/// The watched cache, while it's alive.
|
||||
pub fn cache(&self) -> Option<Arc<PreviewCache>> {
|
||||
self.0.as_ref().and_then(|(cache, _)| cache.upgrade())
|
||||
}
|
||||
|
||||
/// `cache` at `generation` is the active one now. If it was cleared or replaced since the
|
||||
/// last observation, invalidate `index` and return the keys whose files should be deleted.
|
||||
pub fn observe(&mut self, cache: &Arc<PreviewCache>, generation: u64, index: &mut ThumbIndex) -> Vec<String> {
|
||||
let changed = self.0.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation);
|
||||
self.0 = Some((Arc::downgrade(cache), generation));
|
||||
if changed { index.invalidate() } else { Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn clearing_index_changes_disk_namespace_and_survives_restart() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let mut index = ThumbIndex::default();
|
||||
let old = index.cache_key(key);
|
||||
assert_eq!(old, key.to_string());
|
||||
index.insert(&old, 100);
|
||||
assert_eq!(index.invalidate(), vec![old.clone()]);
|
||||
let new = index.cache_key(key);
|
||||
assert_ne!(old, new);
|
||||
index.insert(&new, 100);
|
||||
let reopened = ThumbIndex::from_json(&index.to_json());
|
||||
assert_eq!(reopened.cache_key(key), new);
|
||||
assert!(!reopened.contains(&old));
|
||||
assert!(reopened.contains(&new));
|
||||
}
|
||||
|
||||
// Issue #323: a worker given a zoomed view's window renders that window, not the whole frame
|
||||
#[test]
|
||||
fn a_window_job_crosses_the_wire_and_renders_the_same_window() {
|
||||
use lightcraft_engine::pipeline::PixelWindow;
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 };
|
||||
let job = s.region_job(id, 1920, 1280, win, true).unwrap();
|
||||
let wire = WireJob::from_job(&job, false, "Region");
|
||||
let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
|
||||
assert_eq!(wire.request().window, Some(win));
|
||||
let mut core = WorkerCore::default();
|
||||
let a = core.render(&wire, None).unwrap();
|
||||
let b = job.run().rendered.unwrap();
|
||||
assert_eq!((a.image.width, a.image.height), (160, 120));
|
||||
assert_eq!((b.image.width, b.image.height), (160, 120));
|
||||
// the worker renders on the CPU; the engine job uses a GPU when the machine has one, which
|
||||
// stays within docs/gpu-pipeline.md's bound (≤ 3 LSB per channel) rather than bit-exact
|
||||
let max = a.image.data.iter().zip(&b.image.data).flat_map(|(p, q)| (0..3).map(move |c| p[c].abs_diff(q[c]))).max();
|
||||
assert!(max.is_some_and(|m| m <= 3), "max channel difference {max:?}");
|
||||
}
|
||||
|
||||
// a job from before windows existed has none
|
||||
#[test]
|
||||
fn a_job_without_a_window_field_has_no_window() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb");
|
||||
let mut v = serde_json::to_value(&wire).unwrap();
|
||||
v.as_object_mut().unwrap().remove("window");
|
||||
let old: WireJob = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.request().window, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_job_renders_like_the_engine() {
|
||||
let mut s = Session::with_demo();
|
||||
@@ -291,10 +413,13 @@ mod tests {
|
||||
let hash = crate::store::content_hash(&bytes);
|
||||
let origin = Source::File { path: crate::store::original_path(&hash, "a.png") };
|
||||
let job = WireJob {
|
||||
cache_generation: 0,
|
||||
source_identity: None,
|
||||
level: SourceLevel::Thumb,
|
||||
origin,
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
relative_wb: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
@@ -307,6 +432,7 @@ mod tests {
|
||||
thumb_cached: false,
|
||||
stages: Some("Main".into()),
|
||||
overlay: (0, 0.0),
|
||||
window: None,
|
||||
};
|
||||
let mut core = WorkerCore::default();
|
||||
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));
|
||||
@@ -314,6 +440,68 @@ mod tests {
|
||||
let r = core.render(&job, Some(&bytes)).unwrap();
|
||||
assert_eq!(r.image.width, 32);
|
||||
assert_eq!(core.needs_original(&job), None, "decoded source is kept");
|
||||
let mut reloaded = job.clone();
|
||||
reloaded.source_identity = Some("changed content".into());
|
||||
assert_eq!(core.needs_original(&reloaded).as_deref(), Some(hash.as_str()), "content reload must not reuse an old decoded source");
|
||||
let mut refreshed = job.clone();
|
||||
refreshed.cache_generation = 1;
|
||||
assert_eq!(core.needs_original(&refreshed).as_deref(), Some(hash.as_str()), "explicit refresh must not reuse the old decoded source");
|
||||
}
|
||||
|
||||
/// The persisted index as a restarted page loads it: one stored thumbnail.
|
||||
fn persisted_index(key: Hash128) -> (ThumbIndex, String) {
|
||||
let mut index = ThumbIndex::default();
|
||||
let hex = index.cache_key(key);
|
||||
index.insert(&hex, 100);
|
||||
(ThumbIndex::from_json(&index.to_json()), hex)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_previews_before_the_first_request_invalidates_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let cache = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&cache);
|
||||
let mut gone = Vec::new();
|
||||
// each frame (`Workers::finished`) observes the watched cache, even with no requests
|
||||
let mut frame = |watch: &mut CacheWatch, index: &mut ThumbIndex| {
|
||||
if let Some(c) = watch.cache() {
|
||||
gone.extend(watch.observe(&c, c.generation(), index));
|
||||
}
|
||||
};
|
||||
frame(&mut watch, &mut index); // an empty filtered view: no render request yet
|
||||
cache.clear(); // File ▸ Clear Preview Cache
|
||||
frame(&mut watch, &mut index);
|
||||
// the filter is removed: the first thumbnail request (`Workers::try_start`)
|
||||
gone.extend(watch.observe(&cache, cache.generation(), &mut index));
|
||||
let disk_key = index.cache_key(key);
|
||||
assert!(!index.touch(&disk_key), "the pre-clear thumbnail must not be read back");
|
||||
assert_eq!(gone, vec![old.clone()], "the stored file is deleted");
|
||||
assert_ne!(disk_key, old, "a fresh namespace: a late old write can't come back");
|
||||
assert!(index.dirty, "the invalidated index is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_cache_before_the_first_request_invalidates_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let first = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&first);
|
||||
let other = Arc::new(PreviewCache::memory(1 << 20)); // another library, generation 0 again
|
||||
assert_eq!(watch.observe(&other, other.generation(), &mut index), vec![old]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_start_keeps_stored_thumbnails() {
|
||||
let key = lightcraft_preview::hash_bytes(b"thumbnail");
|
||||
let (mut index, old) = persisted_index(key);
|
||||
let cache = Arc::new(PreviewCache::memory(1 << 20));
|
||||
let mut watch = CacheWatch::new(&cache);
|
||||
for _ in 0..3 {
|
||||
assert!(watch.observe(&cache, cache.generation(), &mut index).is_empty());
|
||||
}
|
||||
assert_eq!(index.cache_key(key), old);
|
||||
assert!(index.touch(&old), "persisted thumbnails survive a restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array};
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::pipeline::Rendered;
|
||||
use lightcraft_preview::{Hash128, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
|
||||
|
||||
/// Storage key of the thumbnail index.
|
||||
pub const THUMB_INDEX: &str = "thumbs/index.json";
|
||||
@@ -155,6 +155,10 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
// main side
|
||||
|
||||
struct Busy {
|
||||
disk_key: Option<String>,
|
||||
namespace: u64,
|
||||
request_id: u64,
|
||||
cache_generation: u64,
|
||||
id: u32,
|
||||
slot: Slot,
|
||||
photo: PhotoId,
|
||||
@@ -171,12 +175,17 @@ struct W {
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// The rendered-thumbnail cache the stored thumbnails belong to.
|
||||
preview_generation: CacheWatch,
|
||||
workers: Vec<W>,
|
||||
done: Vec<(Slot, RenderResult, f64)>,
|
||||
next_id: u32,
|
||||
/// (photo, level) → worker that last decoded it.
|
||||
affinity: HashMap<(PhotoId, SourceLevel), usize>,
|
||||
index: ThumbIndex,
|
||||
/// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a
|
||||
/// worker message, where the session is out of reach; the app refreshes it every frame.
|
||||
budget: u64,
|
||||
backend: Option<Backend>,
|
||||
ctx: egui::Context,
|
||||
/// Jobs finished by workers / inline (for diagnostics).
|
||||
@@ -185,19 +194,46 @@ struct Inner {
|
||||
inline_this_frame: bool,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn observe_cache(&mut self, cache: &Arc<PreviewCache>, generation: u64) {
|
||||
let gone = self.preview_generation.observe(cache, generation, &mut self.index);
|
||||
if !gone.is_empty()
|
||||
&& let Some(backend) = self.backend.clone()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for key in gone {
|
||||
let _ = backend.remove(&thumb_storage_key(&key)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main thread's handle on the render workers.
|
||||
#[derive(Clone)]
|
||||
pub struct Workers(Rc<RefCell<Inner>>);
|
||||
|
||||
impl Workers {
|
||||
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, ctx: egui::Context) -> Workers {
|
||||
/// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
|
||||
/// now: watching it from the start, a clear before the first render request still counts.
|
||||
pub fn start(
|
||||
n: usize,
|
||||
store: &str,
|
||||
backend: Option<Backend>,
|
||||
index: ThumbIndex,
|
||||
cache: &Arc<PreviewCache>,
|
||||
budget: u64,
|
||||
ctx: egui::Context,
|
||||
) -> Workers {
|
||||
let w = Workers(Rc::new(RefCell::new(Inner {
|
||||
preview_generation: CacheWatch::new(cache),
|
||||
workers: Vec::new(),
|
||||
done: Vec::new(),
|
||||
next_id: 0,
|
||||
affinity: HashMap::new(),
|
||||
index,
|
||||
budget,
|
||||
backend,
|
||||
ctx,
|
||||
remote_done: 0,
|
||||
@@ -248,7 +284,16 @@ impl Workers {
|
||||
w.dead = true;
|
||||
w.worker.terminate();
|
||||
if let Some(b) = w.busy.take() {
|
||||
let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None, quick: None };
|
||||
let r = RenderResult {
|
||||
request_id: b.request_id,
|
||||
source_key: None,
|
||||
photo: b.photo,
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered: Err(msg),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
g.done.push((b.slot, r, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -284,16 +329,21 @@ impl Workers {
|
||||
};
|
||||
if let Ok(r) = &rendered
|
||||
&& let Some((cache, key)) = &b.cache
|
||||
&& cache.generation() == b.cache_generation
|
||||
&& b.namespace == g.index.namespace()
|
||||
{
|
||||
cache.put(*key, Arc::new(r.image.clone()));
|
||||
let hex = key.to_string();
|
||||
cache.put_at(b.cache_generation, *key, Arc::new(r.image.clone()));
|
||||
let hex = b.disk_key.clone().unwrap_or_else(|| key.to_string());
|
||||
let stored = get(&data, "stored").as_f64().unwrap_or(0.0) as u64;
|
||||
if get(&data, "cacheMiss").is_truthy() {
|
||||
g.index.remove(&hex);
|
||||
}
|
||||
if stored > 0 {
|
||||
g.index.insert(&hex, stored);
|
||||
let gone = g.index.prune(DISK_CACHE_BYTES);
|
||||
// The session's own budget (`library.preferences ▸ cacheMb`), not the native
|
||||
// default: the browser accepted the setting, so it has to be the one enforced
|
||||
// (#245).
|
||||
let gone = g.index.prune(g.budget);
|
||||
if !gone.is_empty()
|
||||
&& let Some(be) = g.backend.clone()
|
||||
{
|
||||
@@ -307,8 +357,31 @@ impl Workers {
|
||||
}
|
||||
}
|
||||
g.affinity.insert((b.photo, b.level), i);
|
||||
// A cleared worker may finish writing after the initial deletion. Its old namespace
|
||||
// is never reused, so removing this file cannot delete a replacement's cache entry.
|
||||
if let Some((cache, _)) = &b.cache
|
||||
&& (cache.generation() != b.cache_generation || b.namespace != g.index.namespace())
|
||||
&& let (Some(backend), Some(key)) = (g.backend.clone(), b.disk_key.clone())
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = backend.remove(&thumb_storage_key(&key)).await;
|
||||
});
|
||||
}
|
||||
g.remote_done += 1;
|
||||
g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None, quick: None }, ms));
|
||||
g.done.push((
|
||||
b.slot,
|
||||
RenderResult {
|
||||
request_id: b.request_id,
|
||||
source_key: None,
|
||||
photo: b.photo,
|
||||
level: b.level,
|
||||
key: b.key,
|
||||
rendered,
|
||||
loaded: None,
|
||||
quick: None,
|
||||
},
|
||||
ms,
|
||||
));
|
||||
}
|
||||
|
||||
/// The thumbnail index as JSON if it changed since the last call (the host saves it).
|
||||
@@ -328,19 +401,30 @@ impl Workers {
|
||||
let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count();
|
||||
(alive, ready, g.remote_done, g.inline_done)
|
||||
}
|
||||
|
||||
/// The budget the thumbnail index is pruned to. The app refreshes it from the session every
|
||||
/// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail.
|
||||
pub fn set_budget(&self, bytes: u64) {
|
||||
self.0.borrow_mut().budget = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOffload for Workers {
|
||||
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
let g = &mut *g;
|
||||
if let Some((cache, _)) = job.cache.as_ref().or(job.view_cache.as_ref()) {
|
||||
g.observe_cache(cache, job.cache_generation);
|
||||
}
|
||||
// rendered thumbnail in memory: no worker needed
|
||||
if let Some((cache, key)) = &job.cache
|
||||
&& let Some(img) = cache.get(*key)
|
||||
&& let Some(img) = cache.get_at(job.cache_generation, *key)
|
||||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
let r = RenderResult {
|
||||
request_id: job.request_id,
|
||||
source_key: job.source_key,
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
@@ -374,25 +458,45 @@ impl RenderOffload for Workers {
|
||||
let Some(i) = pick else { return Some(job) };
|
||||
// decided here, so the next job for this photo follows even before this one finishes
|
||||
g.affinity.insert((job.photo, job.level), i);
|
||||
let thumb_cached = job.cache.as_ref().is_some_and(|(_, k)| g.index.touch(&k.to_string()));
|
||||
let wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
let disk_key = job.cache.as_ref().map(|(_, key)| g.index.cache_key(*key));
|
||||
let thumb_cached = disk_key.as_ref().is_some_and(|key| g.index.touch(key));
|
||||
let mut wire = WireJob::from_job(&job, thumb_cached, &format!("{slot:?}"));
|
||||
// The namespace also changes when a new library's cache starts again at generation zero.
|
||||
wire.cache_generation = g.index.namespace();
|
||||
wire.thumb = disk_key.clone();
|
||||
let Ok(text) = serde_json::to_string(&wire) else { return Some(job) };
|
||||
g.next_id = g.next_id.wrapping_add(1);
|
||||
let id = g.next_id;
|
||||
let msg = Object::new();
|
||||
set(&msg, "id", &id.into());
|
||||
set(&msg, "job", &text.into());
|
||||
let namespace = g.index.namespace();
|
||||
let w = &mut g.workers[i];
|
||||
if let Err(e) = w.worker.post_message(&msg) {
|
||||
log::warn!("render worker {i}: {e:?}");
|
||||
return Some(job);
|
||||
}
|
||||
w.busy = Some(Busy { id, slot, photo: job.photo, level: job.level, key: job.key, cache: job.cache });
|
||||
w.busy = Some(Busy {
|
||||
disk_key,
|
||||
namespace,
|
||||
request_id: job.request_id,
|
||||
cache_generation: job.cache_generation,
|
||||
id,
|
||||
slot,
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
cache: job.cache,
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
|
||||
let mut g = self.0.borrow_mut();
|
||||
// Persist invalidation even when the cleared library has no visible render requests.
|
||||
if let Some(cache) = g.preview_generation.cache() {
|
||||
g.observe_cache(&cache, cache.generation());
|
||||
}
|
||||
g.inline_this_frame = false;
|
||||
std::mem::take(&mut g.done)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,16 @@ default-run = "lightcraft"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["denoise"]
|
||||
denoise = ["lightcraft-engine/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it (HEVC patents); build with `--features heif` to include it.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-engine = { workspace = true, features = ["sam"] }
|
||||
lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
@@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! File-dialog filters that match extensions whatever their letter case.
|
||||
//!
|
||||
//! On Linux, `rfd` turns each extension into a `*.ext` glob for the GTK dialog and for the
|
||||
//! desktop portal (`xdg-desktop-portal` FileChooser), and both match globs case-sensitively, so
|
||||
//! a filter of `nef` hid `DSC_1234.NEF` (issue #342). Windows and macOS ignore case. Every
|
||||
//! dialog filter of the app goes through [`FileDialogExt::add_filter_nocase`] (a test checks
|
||||
//! that nothing else calls `add_filter`), so no extension can hit this.
|
||||
|
||||
use rfd::FileDialog;
|
||||
|
||||
/// The patterns to hand to `rfd` for `exts`, which are plain extensions without the dot.
|
||||
///
|
||||
/// Windows and macOS take the extension literally and ignore case: lower case, de-duplicated.
|
||||
/// Linux (`linux`): `rfd` formats each entry as `*.{entry}`, which GTK 3 and the portal match
|
||||
/// as a glob, so an entry may carry glob syntax. Each extension gives the lower-case form, the
|
||||
/// UPPER-case form (kept in case a portal backend does not do `[...]` classes) and a bracket
|
||||
/// form such as `[nN][eE][fF]` that matches every mix (`.Nef`, `.nEf`, ...).
|
||||
pub fn dialog_extensions_for(exts: &[&str], linux: bool) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |s: String| {
|
||||
if !s.is_empty() && !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
};
|
||||
for ext in exts {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
let lower = ext.to_lowercase();
|
||||
push(lower.clone());
|
||||
if !linux {
|
||||
continue;
|
||||
}
|
||||
push(ext.to_uppercase());
|
||||
// `[` `]` `*` `?` in an extension would already be glob syntax: leave those alone
|
||||
if lower.chars().any(|c| c.is_alphabetic()) && !lower.contains(['[', ']', '*', '?', '\\']) {
|
||||
push(
|
||||
lower
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let up: String = c.to_uppercase().collect();
|
||||
if up.chars().count() == 1 && up != c.to_string() { format!("[{c}{up}]") } else { c.to_string() }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`dialog_extensions_for`] for the platform this is built for.
|
||||
pub fn dialog_extensions(exts: &[&str]) -> Vec<String> {
|
||||
dialog_extensions_for(exts, cfg!(all(unix, not(target_os = "macos"))))
|
||||
}
|
||||
|
||||
/// `add_filter` that matches the extensions in any letter case.
|
||||
pub trait FileDialogExt {
|
||||
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self;
|
||||
}
|
||||
|
||||
impl FileDialogExt for FileDialog {
|
||||
fn add_filter_nocase(self, name: impl Into<String>, exts: &[&str]) -> Self {
|
||||
self.add_filter(name, &dialog_extensions(exts))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn other_platforms_get_lower_case_once() {
|
||||
assert_eq!(dialog_extensions_for(&["NEF", "nef", ".Jpg", "jpg", ""], false), ["nef", "jpg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_gets_lower_upper_and_any_mix() {
|
||||
assert_eq!(dialog_extensions_for(&["nef"], true), ["nef", "NEF", "[nN][eE][fF]"]);
|
||||
// digits have no case; a duplicate adds nothing
|
||||
assert_eq!(dialog_extensions_for(&["mp4", "MP4"], true), ["mp4", "MP4", "[mM][pP]4"]);
|
||||
assert_eq!(dialog_extensions_for(&["7z"], true), ["7z", "7Z", "7[zZ]"]);
|
||||
assert_eq!(dialog_extensions_for(&["123"], true), ["123"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_glob_syntax_is_not_wrapped_again() {
|
||||
assert_eq!(dialog_extensions_for(&["[nN]ef"], true), ["[nn]ef", "[NN]EF"]);
|
||||
}
|
||||
|
||||
/// The Linux patterns match like GTK's glob (`*.{entry}`): checked against a tiny matcher of
|
||||
/// the subset used here (verified against real GTK 3.24 `GtkFileFilter` in the PR notes).
|
||||
fn glob_ext(entry: &str, ext: &str) -> bool {
|
||||
let mut pat = entry.chars().peekable();
|
||||
let mut s = ext.chars();
|
||||
while let Some(p) = pat.next() {
|
||||
let Some(c) = s.next() else { return false };
|
||||
if p == '[' {
|
||||
let mut hit = false;
|
||||
for q in pat.by_ref() {
|
||||
if q == ']' {
|
||||
break;
|
||||
}
|
||||
hit |= q == c;
|
||||
}
|
||||
if !hit {
|
||||
return false;
|
||||
}
|
||||
} else if p != c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
s.next().is_none()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_patterns_match_every_case_mix() {
|
||||
let pats = dialog_extensions_for(&["nef", "cr2", "xmp"], true);
|
||||
let matches = |name: &str| pats.iter().any(|p| glob_ext(p, name));
|
||||
for ok in ["nef", "NEF", "Nef", "nEF", "CR2", "Cr2", "XMP", "xMp"] {
|
||||
assert!(matches(ok), "{ok}");
|
||||
}
|
||||
for bad in ["nex", "ne", "nefs", "jpg", "cr3"] {
|
||||
assert!(!matches(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's own extension list (the one the folder scan uses) is covered whole.
|
||||
#[test]
|
||||
fn import_extensions_are_all_covered_in_both_cases() {
|
||||
let pats = dialog_extensions_for(lightcraft_engine::import::EXTENSIONS, true);
|
||||
for e in lightcraft_engine::import::EXTENSIONS {
|
||||
assert!(pats.iter().any(|p| glob_ext(p, &e.to_uppercase())), "{e}");
|
||||
assert!(pats.iter().any(|p| p == e), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog filter goes through the helper: `main.rs` must not call rfd's own
|
||||
/// `add_filter`.
|
||||
#[test]
|
||||
fn no_dialog_bypasses_the_helper() {
|
||||
for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] {
|
||||
assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase");
|
||||
}
|
||||
assert!(include_str!("main.rs").contains(".add_filter_nocase("));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a
|
||||
//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings folder>/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux
|
||||
//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the
|
||||
//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`),
|
||||
//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS`
|
||||
//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs.
|
||||
//!
|
||||
//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu
|
||||
//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or
|
||||
//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors
|
||||
//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`,
|
||||
//! `warn,lightcraft_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every
|
||||
//! target that starts with it (`lightcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink. Panics that escape everything are added to the
|
||||
//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error).
|
||||
//!
|
||||
//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError, TryLockError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log folder.
|
||||
pub const LOG_FILE: &str = "lightcraft.log";
|
||||
/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise.
|
||||
pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info";
|
||||
|
||||
/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset).
|
||||
///
|
||||
/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or
|
||||
/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value
|
||||
/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`].
|
||||
pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String {
|
||||
let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
if let Some(level) = set(lightcraft_log) {
|
||||
return match level.as_str() {
|
||||
"debug" => "warn,lightcraft*=debug".to_owned(),
|
||||
"info" => "warn,lightcraft*=info".to_owned(),
|
||||
_ => "warn".to_owned(),
|
||||
};
|
||||
}
|
||||
set(rust_log).unwrap_or_else(|| DEFAULT_FILTER.to_owned())
|
||||
}
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
// No file is coming ([`Sink::no_file`]): standard error had the line.
|
||||
} else if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
self.written = 0;
|
||||
self.capped = false;
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
|
||||
/// standard error stays the only sink.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
// A thread that panicked while logging leaves the sink usable: take it back.
|
||||
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
sink.attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
sink.no_file();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only.
|
||||
pub fn no_file(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
|
||||
}
|
||||
|
||||
/// Add a panic report to the log file only (the default panic hook has printed it on standard
|
||||
/// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on
|
||||
/// this very thread while it held the lock, and waiting would hang the app.
|
||||
pub fn record_panic(&self, thread: &str, report: &str) {
|
||||
let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report);
|
||||
let mut sink = match self.sink.try_lock() {
|
||||
Ok(sink) => sink,
|
||||
Err(TryLockError::Poisoned(p)) => p.into_inner(),
|
||||
Err(TryLockError::WouldBlock) => return,
|
||||
};
|
||||
sink.write(&line);
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed
|
||||
/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let var = |name| std::env::var(name).ok();
|
||||
let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref()));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
/// Chain a panic hook after the current one (the engine guard's: standard error plus
|
||||
/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file.
|
||||
pub fn record_panics(logger: &'static AppLogger) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
previous(info);
|
||||
let thread = std::thread::current();
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
let report = format!("panic{at}: {}", lightcraft_engine::guard::panic_message(info.payload()));
|
||||
logger.record_panic(thread.name().unwrap_or("?"), &report);
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
|
||||
use log::Log;
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
/// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and
|
||||
/// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them.
|
||||
fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool {
|
||||
let max = match lightcraft_log {
|
||||
"debug" => LevelFilter::Debug,
|
||||
"info" => LevelFilter::Info,
|
||||
_ => LevelFilter::Warn,
|
||||
};
|
||||
level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("lightcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
assert_eq!(filter_spec(None, None), DEFAULT_FILTER);
|
||||
// An empty variable is the same as an unset one.
|
||||
assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER);
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger.
|
||||
#[test]
|
||||
fn lightcraft_log_keeps_its_meaning() {
|
||||
let targets =
|
||||
["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"];
|
||||
let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace];
|
||||
for value in ["info", "debug", "warn", "error", "verbose", "INFO"] {
|
||||
let f = Filter::parse(&filter_spec(Some(value), None));
|
||||
for target in targets {
|
||||
for level in levels {
|
||||
assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() {
|
||||
assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info");
|
||||
let f = Filter::parse(&filter_spec(None, Some("debug")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Debug);
|
||||
// The app's own variable is the more specific choice.
|
||||
let f = Filter::parse(&filter_spec(Some("info"), Some("trace")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"lightcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,lightcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "lightcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
let _ = filter_spec(Some(spec), Some(spec));
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"),
|
||||
"2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "before the settings directory");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom");
|
||||
record(&logger, log::Level::Debug, "lightcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}"));
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "early");
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
assert!(logger.sink.lock().expect("sink").pending.is_empty());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
record(&logger, log::Level::Warn, "lightcraft", "after the poison");
|
||||
assert!(read(&path).contains("lightcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The panic hook's report lands in the file, and never deadlocks when the panic happened
|
||||
/// while this thread held the sink.
|
||||
#[test]
|
||||
fn a_panic_report_goes_to_the_file_and_never_deadlocks() {
|
||||
let dir = temp_dir("panic");
|
||||
let logger = AppLogger::new(Filter::parse("off"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom");
|
||||
{
|
||||
let _held = logger.sink.lock().expect("lock");
|
||||
logger.record_panic("main", "while the sink is held");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
|
||||
assert!(!text.contains("while the sink is held"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+562
-72
@@ -24,9 +24,12 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
mod dialog_filter;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
@@ -49,8 +52,15 @@ struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
if let Some(m) = self.2.as_mut()
|
||||
&& m.update(&mut self.0, ctx)
|
||||
&& lightcraft_ui_egui::panels::notices::may_close(&mut self.0)
|
||||
{
|
||||
// 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);
|
||||
std::process::exit(0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
@@ -63,40 +73,92 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
log::error!("saving the library failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
/// 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).
|
||||
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
|
||||
}
|
||||
|
||||
/// Written while the GPU compute device is created, removed once that returned
|
||||
/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
|
||||
fn gpu_marker_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("gpu-init.marker"))
|
||||
}
|
||||
|
||||
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
|
||||
/// driver while creating the device), the notice to show — GPU rendering then starts off.
|
||||
///
|
||||
/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
|
||||
/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
|
||||
/// starts off there too, and the next ordinary launch reports and clears it.
|
||||
fn gpu_crash_check(in_memory: bool) -> Option<String> {
|
||||
gpu_crash_check_at(gpu_marker_path(), in_memory)
|
||||
}
|
||||
|
||||
fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
|
||||
use lightcraft_engine::gpu::backend;
|
||||
let left = if in_memory {
|
||||
marker.as_deref().and_then(backend::read_init_marker)
|
||||
} else {
|
||||
let left = marker.as_deref().and_then(backend::take_init_marker);
|
||||
backend::set_init_marker(marker);
|
||||
left
|
||||
};
|
||||
left.map(|what| gpu_crash_notice(&what))
|
||||
}
|
||||
|
||||
fn gpu_crash_notice(what: &str) -> String {
|
||||
format!(
|
||||
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
|
||||
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
|
||||
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
|
||||
)
|
||||
}
|
||||
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::config::config_dir()
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("ui.json"))
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
||||
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
||||
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
||||
/// one that can't be read at all is not written this session (`keep_file`).
|
||||
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return (None, None, false);
|
||||
}
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
///
|
||||
/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
|
||||
/// (issue #164), so a damaged file is left as it is there, not set aside.
|
||||
fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let Some(path) = prefs_path() else { return (None, None, false) };
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
@@ -107,6 +169,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bo
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
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)
|
||||
}
|
||||
Err(e) => {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
||||
@@ -149,8 +215,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
|
||||
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
||||
/// changes, every few seconds when anything else changed, and at exit.
|
||||
///
|
||||
/// Nothing is written while the session is one that promises to save nothing: `--memory`
|
||||
/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
|
||||
/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
|
||||
/// library it opened is saved then).
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
|
||||
path: Option<std::path::PathBuf>,
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
@@ -162,19 +235,26 @@ struct PrefsWriter {
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
self.save_ui(&app.ui, app.library_problem.is_some())
|
||||
}
|
||||
|
||||
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
|
||||
/// opened) saves nothing.
|
||||
fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
|
||||
if self.keep_file || temporary {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(d) = config_dir() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&d)
|
||||
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
||||
path.parent()
|
||||
.map_or(Ok(()), std::fs::create_dir_all)
|
||||
.and_then(|()| write_atomic(&path, &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
self.library = ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -188,8 +268,8 @@ impl PrefsWriter {
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
log::warn!("{e}");
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
@@ -200,9 +280,97 @@ impl PrefsWriter {
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
/// The Windows command that opens `url` in the default browser. `explorer <url>` opened File
|
||||
/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
|
||||
/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
|
||||
/// `?`, `&` and `#` need no escaping.
|
||||
fn windows_open_url_command(url: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("rundll32");
|
||||
c.args(["url.dll,FileProtocolHandler", url]);
|
||||
c
|
||||
}
|
||||
|
||||
/// Explorer's `/select,<path>` switch (issue #272). Explorer parses its own command line and only
|
||||
/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`),
|
||||
/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that
|
||||
/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain
|
||||
/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line
|
||||
/// always holds exactly this one argument.
|
||||
fn explorer_select_arg(path: &str) -> String {
|
||||
let path = path.replace('/', "\\").replace('"', "");
|
||||
let path = match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(),
|
||||
};
|
||||
format!("/select,\"{path}\"")
|
||||
}
|
||||
|
||||
/// `explorer /select,"<path>"`. Rust's own argument quoting wraps the whole switch in quotes
|
||||
/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't
|
||||
/// recognise, so on Windows the switch goes on the command line exactly as built.
|
||||
fn explorer_select_command(path: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
let arg = explorer_select_arg(path);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.raw_arg(arg);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
c.arg(arg);
|
||||
}
|
||||
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 {
|
||||
Services {
|
||||
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
|
||||
// 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.
|
||||
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
|
||||
use lightcraft_ui_egui::pick::PickKind;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let repaint = ctx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
let mut d = rfd::FileDialog::new();
|
||||
if let Some(t) = &req.title {
|
||||
d = d.set_title(t);
|
||||
}
|
||||
if let Some((name, exts)) = &req.filter {
|
||||
d = d.add_filter_nocase(name.clone(), exts);
|
||||
}
|
||||
if let Some(n) = &req.file_name {
|
||||
d = d.set_file_name(n);
|
||||
}
|
||||
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
|
||||
let paths: Vec<String> = match req.kind {
|
||||
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
|
||||
PickKind::File => d.pick_file().map(s).into_iter().collect(),
|
||||
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
|
||||
PickKind::Save => d.save_file().map(s).into_iter().collect(),
|
||||
};
|
||||
// the app may have quit meanwhile: nobody to tell
|
||||
let _ = tx.send(paths);
|
||||
repaint.request_repaint();
|
||||
})
|
||||
.map_err(|e| format!("could not start the file dialog: {e}"))?;
|
||||
Ok(rx)
|
||||
})),
|
||||
log_file: log_file.map(|p| p.display().to_string()),
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.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();
|
||||
@@ -232,7 +400,7 @@ fn services() -> Services {
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").arg(url).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
std::process::Command::new("explorer").arg(url).status()
|
||||
windows_open_url_command(url).status()
|
||||
} else {
|
||||
std::process::Command::new("xdg-open").arg(url).status()
|
||||
};
|
||||
@@ -242,7 +410,7 @@ fn services() -> Services {
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").args(["-R", path]).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
std::process::Command::new("explorer").arg(format!("/select,{path}")).status()
|
||||
explorer_select_command(path).status()
|
||||
} else {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
|
||||
std::process::Command::new("xdg-open").arg(dir).status()
|
||||
@@ -253,12 +421,26 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Photos",
|
||||
&[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
|
||||
"jxl", "gif", "bmp",
|
||||
],
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_denoise_model: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.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()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter_nocase(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
||||
)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
@@ -266,36 +448,34 @@ fn services() -> Services {
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
pick_model_file: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.pick_files()
|
||||
.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()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.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()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Presets")
|
||||
.add_filter("LightCraft Preset", &["lcpreset"])
|
||||
.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_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.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()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -304,8 +484,8 @@ fn services() -> Services {
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.set_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)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -316,30 +496,40 @@ fn services() -> Services {
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
// the library is a folder on disk: backed up with the user's other files
|
||||
backup_library: None,
|
||||
restore_library: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The library session with the folder face models are kept in (`<config>/models`).
|
||||
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();
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
match s.open_library(&dir, seed_demo) {
|
||||
Ok(r) => {
|
||||
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||
eprintln!(
|
||||
"lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||
log::info!(
|
||||
"library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||
dir.display(),
|
||||
s.catalog.len(),
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
@@ -348,7 +538,7 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
log::error!("can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
@@ -366,12 +556,72 @@ OPTIONS:
|
||||
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
|
||||
see docs/control-protocol.md)
|
||||
--version, --help
|
||||
|
||||
ENVIRONMENT:
|
||||
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;
|
||||
optional: LightCraft offers to download it when first needed)
|
||||
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
||||
LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
|
||||
from other crates); else RUST_LOG=<env_logger directives>. Records go to stderr and to
|
||||
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
|
||||
";
|
||||
|
||||
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
|
||||
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
|
||||
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
|
||||
launch_imports_in(args, cwd.as_deref(), home.as_deref())
|
||||
}
|
||||
|
||||
fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
|
||||
use lightcraft_engine::import::{LaunchPath, launch_path};
|
||||
let (mut files, mut notices) = (Vec::new(), Vec::new());
|
||||
for a in args {
|
||||
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
|
||||
Some(LaunchPath::Import(p)) => files.push(p),
|
||||
Some(LaunchPath::Refused { path, why }) => {
|
||||
log::warn!("not importing {path} given at launch: {why}");
|
||||
notices
|
||||
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
(files, notices)
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("logs"))
|
||||
}
|
||||
|
||||
/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is
|
||||
/// the option or variable it came from). Logged before the log file opens, so it is kept for it.
|
||||
fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
let value = value?;
|
||||
let port = value.trim().parse().ok();
|
||||
if port.is_none() {
|
||||
log::warn!("{what} {value:?} is not a port number; no control server");
|
||||
}
|
||||
port
|
||||
}
|
||||
|
||||
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"));
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
alloc_release::install();
|
||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty()));
|
||||
let mut files = Vec::new();
|
||||
let mut seed_demo = true;
|
||||
let mut in_memory = false;
|
||||
@@ -379,7 +629,7 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control" => control_port = control_port_from("--control", Some(args.next().unwrap_or_default())),
|
||||
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
|
||||
"--no-demo" => seed_demo = false,
|
||||
"--memory" | "--demo" => in_memory = true,
|
||||
@@ -398,7 +648,31 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
|
||||
// no file behind. Records logged until now are written to it first.
|
||||
let mut log_file = None;
|
||||
if let Some(logger) = logger {
|
||||
// a --memory session writes nothing (issue #164), a log file included
|
||||
match log_dir().filter(|_| !in_memory) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => {
|
||||
log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display());
|
||||
log_file = Some(path);
|
||||
}
|
||||
// The file sink has given up by now, so this goes to standard error only.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
// what the arguments name, resolved now: an empty or relative argument never becomes a drive
|
||||
// root, and a whole drive or home folder is not walked (issue #374)
|
||||
let (files, launch_notices) = launch_imports(&files);
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// the saved language from the start, so a failed start is reported in it too
|
||||
if let Some(ui) = &prefs {
|
||||
lightcraft_ui_egui::i18n::set_language(ui.language);
|
||||
}
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -408,6 +682,11 @@ fn main() -> eframe::Result {
|
||||
.map(Into::into)
|
||||
});
|
||||
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
|
||||
// GPU compute: off if the preference says so, or if the last launch died creating the device
|
||||
// (issue #136) — before anything can create it
|
||||
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);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
@@ -420,26 +699,43 @@ 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(),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
// 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 (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
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);
|
||||
@@ -449,7 +745,12 @@ fn main() -> eframe::Result {
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
// 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
|
||||
@@ -463,7 +764,28 @@ fn main() -> eframe::Result {
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
);
|
||||
if let Err(e) = &started {
|
||||
startup_failed(&e.to_string(), log_file.as_deref());
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
|
||||
/// message box naming the log file, instead of exiting without a trace — the release builds have
|
||||
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
|
||||
fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
|
||||
log::error!("LightCraft could not open its window: {error}");
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return; // tests and scripts: no dialog to click away
|
||||
}
|
||||
let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title(title)
|
||||
.set_description(text)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -478,6 +800,24 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #374: launch arguments that would walk a whole drive or home folder are reported,
|
||||
/// not imported; empty ones are ignored; the rest are imported as absolute paths.
|
||||
#[test]
|
||||
fn launch_arguments_never_walk_a_drive_or_the_home_folder() {
|
||||
let home = dir("launch");
|
||||
std::fs::create_dir_all(home.join("Trip")).unwrap();
|
||||
let args: Vec<String> = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect();
|
||||
let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home));
|
||||
assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]);
|
||||
assert_eq!(notices.len(), 2, "{notices:?}");
|
||||
assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}");
|
||||
// the working folder is a drive root (a launcher's choice): `.` is never the whole drive
|
||||
let root = home.ancestors().last().unwrap().to_path_buf();
|
||||
let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None);
|
||||
assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}");
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
@@ -485,7 +825,7 @@ mod tests {
|
||||
let d = dir("damaged");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path);
|
||||
let (ui, warning, keep) = load_prefs_at(&path, true);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
@@ -493,10 +833,160 @@ mod tests {
|
||||
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
||||
// a missing file is just "no settings yet"
|
||||
assert_eq!(load_prefs_at(&path).1, None);
|
||||
assert_eq!(load_prefs_at(&path, true).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is
|
||||
/// reported and left where it is, not set aside.
|
||||
#[test]
|
||||
fn memory_session_leaves_a_damaged_ui_json_alone() {
|
||||
let d = dir("damaged-memory");
|
||||
let path = d.join("ui.json");
|
||||
let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#;
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(ui.is_none() && keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() {
|
||||
let d = dir("writer");
|
||||
let path = d.join("sub").join("ui.json");
|
||||
let mut ui = UiState::default();
|
||||
// an ordinary session: a change is written, the folder created on the way
|
||||
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved));
|
||||
// the temporary session (library problem): a further change is not written…
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, true).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), saved);
|
||||
// …until a library opens
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), saved);
|
||||
// --memory: no path, so no file and no folder, whatever changes
|
||||
let d2 = dir("writer-memory");
|
||||
let _ = std::fs::remove_dir_all(&d2);
|
||||
let mut w = PrefsWriter { path: None, ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert!(w.written.is_empty() && !d2.exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create
|
||||
/// the settings folder) nor removes a marker a crashed launch left; it still reports it, and
|
||||
/// the next ordinary launch finds it, reports it and clears it.
|
||||
#[test]
|
||||
fn memory_session_leaves_the_gpu_marker_and_folder_alone() {
|
||||
let d = dir("gpu-marker-memory");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let m = d.join("gpu-init.marker");
|
||||
// nothing left behind: no notice, and the folder is not created
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None);
|
||||
assert!(!d.exists(), "a --memory session must not create the settings folder");
|
||||
// a marker left by a crashed launch: reported, kept
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap();
|
||||
let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap();
|
||||
assert!(notice.contains("GPU rendering is now off"), "{notice}");
|
||||
assert!(m.exists(), "the marker stays for the next ordinary launch");
|
||||
// the next ordinary launch reports it once and removes it
|
||||
assert!(gpu_crash_check_at(Some(m.clone()), false).is_some());
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
assert!(!m.exists());
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None);
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #136: a GPU init marker left by a crashed launch is reported once and removed.
|
||||
#[test]
|
||||
fn gpu_crash_marker_is_reported_once() {
|
||||
let d = dir("gpu-marker");
|
||||
let m = d.join("gpu-init.marker");
|
||||
assert_eq!(lightcraft_engine::gpu::backend::take_init_marker(&m), None);
|
||||
std::fs::write(&m, "GPU device creation started (backends VULKAN | DX12)").unwrap();
|
||||
let what = lightcraft_engine::gpu::backend::take_init_marker(&m).unwrap();
|
||||
let notice = gpu_crash_notice(&what);
|
||||
assert!(notice.contains("GPU rendering is now off") && notice.contains("Use the GPU for rendering"), "{notice}");
|
||||
assert!(!m.exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #136: Windows windows render with DX12 alone (no Vulkan driver loaded) by default.
|
||||
#[test]
|
||||
fn window_backends_follow_the_platform_default() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
let b = n.instance_descriptor.backends;
|
||||
if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
if cfg!(windows) {
|
||||
assert_eq!(b, eframe::wgpu::Backends::DX12);
|
||||
} else if cfg!(target_os = "macos") {
|
||||
assert_eq!(b, eframe::wgpu::Backends::METAL);
|
||||
}
|
||||
}
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #471: the window's DX12 shaders compile with FXC — wgpu's default loaded any
|
||||
/// `dxcompiler.dll` on the search path, and one without `dxil.dll` kept the window from opening.
|
||||
#[test]
|
||||
fn window_compiles_dx12_shaders_with_fxc() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
assert!(matches!(n.instance_descriptor.backend_options.dx12.shader_compiler, eframe::wgpu::Dx12Compiler::Fxc));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn windows_links_open_in_the_browser() {
|
||||
let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200";
|
||||
let c = windows_open_url_command(url);
|
||||
assert_eq!(c.get_program(), "rundll32");
|
||||
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
|
||||
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
|
||||
}
|
||||
|
||||
/// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and
|
||||
/// without the verbatim prefix, even when the path has spaces (else it opens Documents).
|
||||
#[test]
|
||||
fn explorer_select_quotes_the_path() {
|
||||
assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#);
|
||||
assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#);
|
||||
assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#);
|
||||
assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#);
|
||||
assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#);
|
||||
// a `"` can't end the quoted path and add arguments to the raw command line
|
||||
assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#);
|
||||
let c = explorer_select_command(r"C:\My Photos\a.jpg");
|
||||
assert_eq!(c.get_program(), "explorer");
|
||||
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
|
||||
assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_is_not_a_number_is_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
|
||||
assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001));
|
||||
assert_eq!(control_port_from("--control", Some("nope".into())), None);
|
||||
assert_eq!(control_port_from("--control", Some(String::new())), None);
|
||||
assert_eq!(control_port_from("--control", Some("70000".into())), None);
|
||||
assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_json_is_written_atomically() {
|
||||
let d = dir("atomic");
|
||||
@@ -511,7 +1001,7 @@ mod tests {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
let ui = UiState::default();
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
||||
let (loaded, warning, _) = load_prefs_at(&path);
|
||||
let (loaded, warning, _) = load_prefs_at(&path, true);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts,
|
||||
//! enabled and checked state, and live labels ("Undo Exposure").
|
||||
//!
|
||||
//! Key handling: a native key equivalent consumes the key press before egui sees it, so
|
||||
//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by
|
||||
//! the egui shortcut handler (nothing fires twice);
|
||||
//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a
|
||||
//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose
|
||||
//! winit view always takes it, so a menu item never fires from it. Hence
|
||||
//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in
|
||||
//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires
|
||||
//! twice); the others stay on their menu items for display and egui runs them;
|
||||
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
|
||||
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
|
||||
//! back when the field loses focus.
|
||||
@@ -51,6 +54,9 @@ pub struct NativeMenu {
|
||||
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
|
||||
structure: String,
|
||||
text_focus: bool,
|
||||
/// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
|
||||
/// press reaches it.
|
||||
capturing: bool,
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
|
||||
@@ -146,9 +152,21 @@ fn yields_to_text(sc: &str) -> bool {
|
||||
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
|
||||
}
|
||||
|
||||
/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function
|
||||
/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run
|
||||
/// those even though their menu item shows the key.
|
||||
fn menu_delivers(sc: &str) -> bool {
|
||||
sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::<u8>().is_ok()))
|
||||
}
|
||||
|
||||
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
|
||||
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
|
||||
installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn label_text(s: &str) -> String {
|
||||
s.replace('&', "&&")
|
||||
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
|
||||
}
|
||||
|
||||
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
||||
@@ -171,7 +189,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
||||
out.push(';');
|
||||
}
|
||||
}
|
||||
let mut s = String::new();
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
|
||||
for (t, items) in bar {
|
||||
s.push_str(t);
|
||||
walk(items, &mut s);
|
||||
@@ -196,7 +214,7 @@ impl NativeMenu {
|
||||
let _ = tx.send(e.id.0);
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
|
||||
m.rebuild(app);
|
||||
app.native_menu = true;
|
||||
m
|
||||
@@ -210,30 +228,30 @@ impl NativeMenu {
|
||||
|
||||
// the application menu
|
||||
let app_menu = Submenu::new("LightCraft", true);
|
||||
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
|
||||
let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))),
|
||||
&PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))),
|
||||
&PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&quit,
|
||||
]);
|
||||
let _ = self.menu.append(&app_menu);
|
||||
|
||||
for (title, nodes) in &bar {
|
||||
let sub = Submenu::new(title, true);
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let sub = Submenu::new(label_text(title), true);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(Some("Zoom")),
|
||||
&PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))),
|
||||
&PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
]);
|
||||
}
|
||||
@@ -245,21 +263,28 @@ impl NativeMenu {
|
||||
.collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == app.ui.language.tr("File") {
|
||||
if title == "File" {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
|
||||
]);
|
||||
}
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))),
|
||||
]);
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
if title == app.ui.language.tr("Help") {
|
||||
if title == "Help" {
|
||||
sub.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
let _ = self.menu.append(&sub);
|
||||
}
|
||||
self.menu.init_for_nsapp();
|
||||
self.text_focus = false;
|
||||
self.capturing = false;
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
|
||||
@@ -282,12 +307,23 @@ impl NativeMenu {
|
||||
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
|
||||
let handle = match checked {
|
||||
Some(c) => {
|
||||
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
|
||||
let it = CheckMenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
*c,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Check(it)
|
||||
}
|
||||
None => {
|
||||
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
|
||||
let it = MenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Plain(it)
|
||||
}
|
||||
@@ -312,22 +348,22 @@ impl NativeMenu {
|
||||
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
app.native_shortcuts = self
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.accel.is_some())
|
||||
.filter_map(|i| i.shortcut.clone())
|
||||
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
|
||||
.collect::<HashSet<_>>();
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
// (none while the keymap editor records a shortcut: every key goes to it)
|
||||
app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
/// accelerators with the app state. Returns whether Quit was chosen: the caller saves
|
||||
/// everything and exits the process itself (closing the viewport while AppKit terminates the
|
||||
/// app can crash in winit's teardown, PR #444).
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
let mut quit = false;
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
quit = true;
|
||||
continue;
|
||||
}
|
||||
if key == "app.about" || key == SETTINGS {
|
||||
@@ -342,7 +378,7 @@ impl NativeMenu {
|
||||
let bar = menu_bar(app);
|
||||
if structure_of(&bar) != self.structure {
|
||||
self.rebuild(app);
|
||||
return;
|
||||
return quit;
|
||||
}
|
||||
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
|
||||
for n in nodes {
|
||||
@@ -352,8 +388,8 @@ impl NativeMenu {
|
||||
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
|
||||
if it.label != *label {
|
||||
match &it.handle {
|
||||
Handle::Plain(h) => h.set_text(label_text(label)),
|
||||
Handle::Check(h) => h.set_text(label_text(label)),
|
||||
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
}
|
||||
it.label = label.clone();
|
||||
}
|
||||
@@ -379,14 +415,16 @@ impl NativeMenu {
|
||||
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
|
||||
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
if focus != self.text_focus {
|
||||
let capturing = app.recording_shortcut.is_some();
|
||||
if focus != self.text_focus || capturing != self.capturing {
|
||||
self.text_focus = focus;
|
||||
self.capturing = capturing;
|
||||
for it in self.items.values() {
|
||||
let Some(sc) = it.shortcut.as_deref() else { continue };
|
||||
if it.accel.is_none() || !yields_to_text(sc) {
|
||||
if it.accel.is_none() {
|
||||
continue;
|
||||
}
|
||||
let accel = if focus { None } else { it.accel };
|
||||
let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
|
||||
let _ = match &it.handle {
|
||||
Handle::Plain(h) => h.set_accelerator(accel),
|
||||
Handle::Check(h) => h.set_accelerator(accel),
|
||||
@@ -394,6 +432,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
quit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +440,27 @@ impl NativeMenu {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The menu model's titles stay English, so only the language can tell the native menu to
|
||||
/// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
|
||||
/// languages — must change the structure key.
|
||||
#[test]
|
||||
fn switching_language_rebuilds_native_menu_structure() {
|
||||
use lightcraft_ui_egui::i18n::{Locale, set_language};
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
set_language(Locale::En);
|
||||
let bar = menu_bar(&app);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for language in Locale::ALL {
|
||||
set_language(*language);
|
||||
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
|
||||
}
|
||||
set_language(Locale::ZhHant);
|
||||
assert_eq!(label_text("File"), "檔案");
|
||||
assert_eq!(label_text("Settings…"), "設定…");
|
||||
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_map_to_accelerators() {
|
||||
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
|
||||
@@ -428,4 +488,39 @@ mod tests {
|
||||
assert!(accelerator(sc).is_some(), "{sc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must
|
||||
/// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own.
|
||||
#[test]
|
||||
fn single_key_shortcuts_stay_with_egui() {
|
||||
assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11"));
|
||||
assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F"));
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
fn all(n: &[MenuNode], out: &mut Vec<String>) {
|
||||
for x in n {
|
||||
match x {
|
||||
MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()),
|
||||
MenuNode::Submenu { children, .. } => all(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut installed = Vec::new();
|
||||
for (_, v) in menu_bar(&app) {
|
||||
all(&v, &mut installed);
|
||||
}
|
||||
for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar");
|
||||
}
|
||||
let owned = owned_by_menu(installed.iter().map(String::as_str), false);
|
||||
for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu");
|
||||
assert!(!owned.contains(sc), "{sc} must be left to egui");
|
||||
}
|
||||
for sc in ["Cmd+Shift+H", "F2"] {
|
||||
assert!(owned.contains(sc), "{sc} is run by the menu bar");
|
||||
}
|
||||
// while typing, the menu bar gives up F2 too (the text field has it)
|
||||
assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2"));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none |
|
||||
| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none |
|
||||
| `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/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/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 |
|
||||
@@ -20,7 +21,12 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| 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 |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
|
||||
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
|
||||
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"model":"ILCE-7M4","files":597,"samples":2182876,"matrix":[[1.06975,-0.0709886,-0.000770444],[-0.0169822,1.08012,-0.0651276],[-0.114835,-0.601525,1.74804]],"hue_sat":{"hue_divisions":72,"sat_divisions":5,"val_divisions":5,"data":[[0.0,1.0,1.0],[-0.819008,1.14375,1.0],[-6.07446,1.10974,1.0],[-4.0478,1.04285,1.0],[-0.546764,1.00329,1.0],[0.0,1.0,1.0],[-1.0412,1.10597,1.0],[-7.22645,1.05878,1.0],[-7.30635,0.989387,1.0],[-5.18589,0.974522,1.0],[0.0,1.0,1.0],[-1.02364,1.06303,1.0],[-6.94669,1.01579,1.0],[-7.47614,0.955435,1.0],[-7.02579,0.940854,1.0],[0.0,1.0,1.0],[-0.29852,1.04349,1.0],[-6.37202,1.00021,1.0],[-7.52098,0.935457,1.0],[-7.45702,0.922239,1.0],[0.0,1.0,1.0],[0.367464,1.03266,1.0],[-4.96951,0.996822,1.0],[-7.41706,0.948709,1.0],[-8.04735,0.91672,1.0],[0.0,1.0,1.0],[0.0388419,1.02587,1.0],[-4.47266,1.01282,1.0],[-7.26834,0.987279,1.0],[-9.02713,0.921453,1.0],[0.0,1.0,1.0],[-0.297476,1.02333,1.0],[-4.6323,1.00993,1.0],[-8.57248,0.962973,1.0],[-12.2375,0.872088,1.0],[0.0,1.0,1.0],[-0.785867,1.02786,1.0],[-4.73124,1.00876,1.0],[-9.09856,0.955343,1.0],[-12.4324,0.875472,1.0],[0.0,1.0,1.0],[-0.444072,1.03863,1.0],[-3.94773,1.01784,1.0],[-7.97996,0.975996,1.0],[-10.673,0.917401,1.0],[0.0,1.0,1.0],[1.22651,1.0559,1.0],[-1.89459,1.02934,1.0],[-5.54545,0.993046,1.0],[-8.48877,0.941628,1.0],[0.0,1.0,1.0],[3.24516,1.08292,1.0],[1.20522,1.0577,1.0],[-0.826224,1.01668,1.0],[-3.71884,0.946733,1.0],[0.0,1.0,1.0],[4.68802,1.12039,1.0],[3.23147,1.08619,1.0],[1.84476,1.04,1.0],[0.0970375,0.962298,1.0],[0.0,1.0,1.0],[5.36041,1.16313,1.0],[4.5937,1.10933,1.0],[3.83314,1.05056,1.0],[3.122,0.959876,1.0],[0.0,1.0,1.0],[5.22222,1.17824,1.0],[5.09129,1.1142,1.0],[4.99294,1.04813,1.0],[5.01485,0.957528,1.0],[0.0,1.0,1.0],[4.34382,1.18446,1.0],[4.9457,1.11351,1.0],[5.56715,1.04462,1.0],[6.27579,0.961892,1.0],[0.0,1.0,1.0],[3.5428,1.18985,1.0],[4.9116,1.11394,1.0],[6.11507,1.04098,1.0],[7.03638,0.964331,1.0],[0.0,1.0,1.0],[2.92641,1.20663,1.0],[4.57132,1.1281,1.0],[5.96964,1.04895,1.0],[6.98579,0.96261,1.0],[0.0,1.0,1.0],[2.07123,1.23257,1.0],[3.51736,1.15454,1.0],[4.85051,1.06995,1.0],[5.97805,0.971597,1.0],[0.0,1.0,1.0],[0.869122,1.26762,1.0],[2.22009,1.19021,1.0],[3.54297,1.10536,1.0],[4.31783,1.00254,1.0],[0.0,1.0,1.0],[-0.409355,1.31042,1.0],[0.778039,1.23845,1.0],[1.87845,1.15455,1.0],[1.53759,1.02961,1.0],[0.0,1.0,1.0],[-1.82067,1.35265,1.0],[-0.910751,1.28551,1.0],[-0.100378,1.18585,1.0],[0.0425375,1.01463,1.0],[0.0,1.0,1.0],[-3.26659,1.391,1.0],[-2.50117,1.33263,1.0],[-1.55789,1.18102,1.0],[-0.0853484,1.00594,1.0],[0.0,1.0,1.0],[-4.92446,1.43632,1.0],[-3.92066,1.35798,1.0],[-1.66274,1.11605,1.0],[-0.0358972,1.00178,1.0],[0.0,1.0,1.0],[-5.82474,1.46137,1.0],[-4.22466,1.34716,1.0],[-0.64525,1.04813,1.0],[-0.00357121,1.00023,1.0],[0.0,1.0,1.0],[-7.0672,1.47242,1.0],[-5.38667,1.32663,1.0],[-0.553542,1.02668,1.0],[-0.00188478,1.00008,1.0],[0.0,1.0,1.0],[-8.37522,1.43136,1.0],[-6.13631,1.28174,1.0],[-0.515332,1.01808,1.0],[-0.0015762,1.00005,1.0],[0.0,1.0,1.0],[-10.5196,1.39935,1.0],[-7.49316,1.24442,1.0],[-0.517621,1.01282,1.0],[-0.001445,1.00003,1.0],[0.0,1.0,1.0],[-10.5952,1.3534,1.0],[-7.81548,1.20414,1.0],[-0.63473,1.0111,1.0],[-0.00208267,1.00003,1.0],[0.0,1.0,1.0],[-10.879,1.32051,1.0],[-8.3374,1.18517,1.0],[-0.91817,1.01139,1.0],[-0.00474972,1.00003,1.0],[0.0,1.0,1.0],[-11.8094,1.29525,1.0],[-9.18818,1.17522,1.0],[-0.968552,1.01157,1.0],[-0.00411555,1.00003,1.0],[0.0,1.0,1.0],[-12.2038,1.27777,1.0],[-8.97374,1.15856,1.0],[-0.751018,1.00872,1.0],[-0.00250012,1.00002,1.0],[0.0,1.0,1.0],[-12.0564,1.2709,1.0],[-8.38288,1.15926,1.0],[-0.643806,1.00878,1.0],[-0.00202184,1.00002,1.0],[0.0,1.0,1.0],[-12.8403,1.25618,1.0],[-8.85664,1.14517,1.0],[-0.646111,1.00756,1.0],[-0.00195373,1.00001,1.0],[0.0,1.0,1.0],[-13.2604,1.25094,1.0],[-8.74013,1.11989,1.0],[-0.556009,1.00457,1.0],[-0.00173404,1.00001,1.0],[0.0,1.0,1.0],[-13.3939,1.24388,1.0],[-8.23916,1.11659,1.0],[-0.376536,1.00355,1.0],[-0.000916476,1.00001,1.0],[0.0,1.0,1.0],[-13.6578,1.24212,1.0],[-7.97883,1.11035,1.0],[-0.409859,1.00287,1.0],[-0.0018203,1.0,1.0],[0.0,1.0,1.0],[-15.7225,1.22414,1.0],[-10.2345,1.10217,1.0],[-0.968177,1.00427,1.0],[-0.00650675,1.00001,1.0],[0.0,1.0,1.0],[-16.1557,1.16423,1.0],[-12.721,1.06603,1.0],[-2.02337,1.00334,1.0],[-0.0134173,1.00001,1.0],[0.0,1.0,1.0],[-17.253,1.09714,1.0],[-15.785,1.02184,1.0],[-4.02264,0.996649,1.0],[-0.0279292,0.999956,1.0],[0.0,1.0,1.0],[-17.6206,1.04331,1.0],[-16.2039,0.99401,1.0],[-4.09728,0.990246,1.0],[-0.0269801,0.999896,1.0],[0.0,1.0,1.0],[-17.2302,0.987569,1.0],[-15.1656,0.964114,1.0],[-3.21398,0.983725,1.0],[-0.0202065,0.999807,1.0],[0.0,1.0,1.0],[-16.2184,0.943678,1.0],[-13.4633,0.938141,1.0],[-3.40639,0.971571,1.0],[-0.00923182,0.999563,1.0],[0.0,1.0,1.0],[-14.5653,0.909699,1.0],[-11.6873,0.915733,1.0],[-5.4654,0.942337,1.0],[-0.124331,0.998194,1.0],[0.0,1.0,1.0],[-11.5122,0.906891,1.0],[-9.31547,0.923874,1.0],[-6.12222,0.939832,1.0],[-0.337538,0.995963,1.0],[0.0,1.0,1.0],[-7.77619,0.92294,1.0]Line truncated
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -1,93 +0,0 @@
|
||||
Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,883 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/lightcraft",
|
||||
"branch": "main",
|
||||
"head": "88ea1a804b35709e1fe436e3792adf0428b97daa",
|
||||
"head_date": "2026-10-09T13:05:04Z",
|
||||
"commits": 729,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 83,
|
||||
"commits": 348,
|
||||
"lines_added": 127920,
|
||||
"lines_deleted": 8745,
|
||||
"binary_added": 38,
|
||||
"binary_deleted": 8,
|
||||
"first_commit": "2026-09-30T14:04:12Z",
|
||||
"last_commit": "2026-10-09T13:05:04Z"
|
||||
},
|
||||
{
|
||||
"login": "leipsfur",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 131,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T15:51:58Z",
|
||||
"last_commit": "2026-10-02T15:51:58Z"
|
||||
},
|
||||
{
|
||||
"login": "GlemNL",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 9,
|
||||
"commits": 13,
|
||||
"lines_added": 1233,
|
||||
"lines_deleted": 96,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T21:16:29Z",
|
||||
"last_commit": "2026-10-04T09:42:39Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 9,
|
||||
"lines_added": 1925,
|
||||
"lines_deleted": 119,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T00:28:49Z",
|
||||
"last_commit": "2026-10-09T06:02:21Z"
|
||||
},
|
||||
{
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 755,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 1,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:07:26Z",
|
||||
"last_commit": "2026-10-09T06:04:40Z"
|
||||
},
|
||||
{
|
||||
"login": "voshart",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 26,
|
||||
"lines_added": 30524,
|
||||
"lines_deleted": 4416,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T16:23:27Z",
|
||||
"last_commit": "2026-10-09T02:31:04Z"
|
||||
},
|
||||
{
|
||||
"login": "arata-1972",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 4,
|
||||
"lines_added": 2892,
|
||||
"lines_deleted": 726,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T20:14:35Z",
|
||||
"last_commit": "2026-10-05T22:16:15Z"
|
||||
},
|
||||
{
|
||||
"login": "benlukka",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 49,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T11:18:03Z",
|
||||
"last_commit": "2026-10-06T11:18:03Z"
|
||||
},
|
||||
{
|
||||
"login": "kywk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 72,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T20:21:14Z",
|
||||
"last_commit": "2026-10-06T20:21:14Z"
|
||||
},
|
||||
{
|
||||
"login": "Exalted5373",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 11,
|
||||
"lines_added": 1988,
|
||||
"lines_deleted": 132,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T21:01:23Z",
|
||||
"last_commit": "2026-10-09T01:33:43Z"
|
||||
},
|
||||
{
|
||||
"login": "treyreynolds",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 159,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T22:21:46Z",
|
||||
"last_commit": "2026-10-06T22:21:46Z"
|
||||
},
|
||||
{
|
||||
"login": "kovalskiguinther",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 4328,
|
||||
"lines_deleted": 17,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T23:15:04Z",
|
||||
"last_commit": "2026-10-06T23:15:04Z"
|
||||
},
|
||||
{
|
||||
"login": "cruzer45",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 658,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T01:30:23Z",
|
||||
"last_commit": "2026-10-07T03:53:09Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 4,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T02:27:15Z",
|
||||
"last_commit": "2026-10-07T02:27:15Z"
|
||||
},
|
||||
{
|
||||
"login": "lzjtugjc",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:34:33Z",
|
||||
"last_commit": "2026-10-07T03:34:33Z"
|
||||
},
|
||||
{
|
||||
"login": "itsmunzir",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 1,
|
||||
"lines_added": 46,
|
||||
"lines_deleted": 30,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T07:40:55Z",
|
||||
"last_commit": "2026-10-07T07:40:55Z"
|
||||
},
|
||||
{
|
||||
"login": "haraldh",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 5,
|
||||
"lines_added": 1163,
|
||||
"lines_deleted": 141,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T07:54:27Z",
|
||||
"last_commit": "2026-10-07T13:16:08Z"
|
||||
},
|
||||
{
|
||||
"login": "sakamoto824",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 3649,
|
||||
"lines_deleted": 167,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T09:32:12Z",
|
||||
"last_commit": "2026-10-07T10:54:19Z"
|
||||
},
|
||||
{
|
||||
"login": "anarion80",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 402,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T11:54:51Z",
|
||||
"last_commit": "2026-10-07T11:54:51Z"
|
||||
},
|
||||
{
|
||||
"login": "radialmonster",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 26,
|
||||
"commits": 53,
|
||||
"lines_added": 7227,
|
||||
"lines_deleted": 717,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T12:33:51Z",
|
||||
"last_commit": "2026-10-09T11:31:46Z"
|
||||
},
|
||||
{
|
||||
"login": "bogusyogi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 17,
|
||||
"lines_added": 5124,
|
||||
"lines_deleted": 603,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:06:00Z",
|
||||
"last_commit": "2026-10-08T19:26:28Z"
|
||||
},
|
||||
{
|
||||
"login": "nledford",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 88,
|
||||
"lines_added": 9299,
|
||||
"lines_deleted": 1024,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T17:33:38Z",
|
||||
"last_commit": "2026-10-09T00:59:12Z"
|
||||
},
|
||||
{
|
||||
"login": "MusseJusse",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T18:38:23Z",
|
||||
"last_commit": "2026-10-07T18:38:23Z"
|
||||
},
|
||||
{
|
||||
"login": "Ndsanes",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 332,
|
||||
"lines_deleted": 86,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T19:08:28Z",
|
||||
"last_commit": "2026-10-07T19:08:33Z"
|
||||
},
|
||||
{
|
||||
"login": "RicSchonfelder",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1439,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:03:20Z",
|
||||
"last_commit": "2026-10-07T20:03:20Z"
|
||||
},
|
||||
{
|
||||
"login": "Gremiger",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 19,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T21:43:23Z",
|
||||
"last_commit": "2026-10-07T21:43:23Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 206,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:08:01Z",
|
||||
"last_commit": "2026-10-08T17:04:19Z"
|
||||
},
|
||||
{
|
||||
"login": "sebastianpfluegelmeier",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 4,
|
||||
"lines_added": 23063,
|
||||
"lines_deleted": 17968,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:14:20Z",
|
||||
"last_commit": "2026-10-08T14:38:56Z"
|
||||
},
|
||||
{
|
||||
"login": "agorevski",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 11,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:18:44Z",
|
||||
"last_commit": "2026-10-07T22:18:44Z"
|
||||
},
|
||||
{
|
||||
"login": "andrewyao",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T00:53:19Z",
|
||||
"last_commit": "2026-10-08T00:53:19Z"
|
||||
},
|
||||
{
|
||||
"login": "Hanmo123",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 6,
|
||||
"lines_added": 922,
|
||||
"lines_deleted": 84,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T02:16:44Z",
|
||||
"last_commit": "2026-10-09T05:35:02Z"
|
||||
},
|
||||
{
|
||||
"login": "wondarkus",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 8,
|
||||
"lines_added": 2776,
|
||||
"lines_deleted": 1387,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T02:56:08Z",
|
||||
"last_commit": "2026-10-08T04:23:30Z"
|
||||
},
|
||||
{
|
||||
"login": "xak47d",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 58,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T04:10:21Z",
|
||||
"last_commit": "2026-10-08T04:10:21Z"
|
||||
},
|
||||
{
|
||||
"login": "marceldenzer",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2864,
|
||||
"lines_deleted": 261,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T05:27:32Z",
|
||||
"last_commit": "2026-10-08T05:27:32Z"
|
||||
},
|
||||
{
|
||||
"login": "bflatastic",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 25,
|
||||
"commits": 2,
|
||||
"lines_added": 108,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T06:30:19Z",
|
||||
"last_commit": "2026-10-08T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"login": "wstock",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 8,
|
||||
"lines_added": 834,
|
||||
"lines_deleted": 67,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T07:45:39Z",
|
||||
"last_commit": "2026-10-08T11:55:57Z"
|
||||
},
|
||||
{
|
||||
"login": "marek-hubar",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 212,
|
||||
"lines_deleted": 29,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T09:01:44Z",
|
||||
"last_commit": "2026-10-08T09:01:44Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 762,
|
||||
"lines_deleted": 34,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T11:00:29Z",
|
||||
"last_commit": "2026-10-08T11:00:29Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 50,
|
||||
"lines_deleted": 50,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:12:53Z",
|
||||
"last_commit": "2026-10-08T14:01:45Z"
|
||||
},
|
||||
{
|
||||
"login": "Issuenate",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 107,
|
||||
"lines_deleted": 12,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:26:27Z",
|
||||
"last_commit": "2026-10-08T12:28:18Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 231,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 6,
|
||||
"lines_added": 336,
|
||||
"lines_deleted": 29,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:47Z",
|
||||
"last_commit": "2026-10-09T08:45:45Z"
|
||||
},
|
||||
{
|
||||
"login": "peiyan0",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 215,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T15:36:56Z",
|
||||
"last_commit": "2026-10-09T08:09:23Z"
|
||||
},
|
||||
{
|
||||
"login": "hemna",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 459,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T16:45:33Z",
|
||||
"last_commit": "2026-10-08T16:45:33Z"
|
||||
},
|
||||
{
|
||||
"login": "joshuaswarren",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 349,
|
||||
"lines_deleted": 18,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T16:47:59Z",
|
||||
"last_commit": "2026-10-08T16:47:59Z"
|
||||
},
|
||||
{
|
||||
"login": "wrgdbtry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 2733,
|
||||
"lines_deleted": 63,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T19:22:13Z",
|
||||
"last_commit": "2026-10-08T19:22:13Z"
|
||||
},
|
||||
{
|
||||
"login": "parkerdotexe",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 712,
|
||||
"lines_deleted": 84,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T19:54:50Z",
|
||||
"last_commit": "2026-10-08T19:54:50Z"
|
||||
},
|
||||
{
|
||||
"login": "ragtux",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 805,
|
||||
"lines_deleted": 50,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T22:50:18Z",
|
||||
"last_commit": "2026-10-08T23:42:19Z"
|
||||
},
|
||||
{
|
||||
"login": "sanyamsingh001",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 431,
|
||||
"lines_deleted": 19,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T03:19:47Z",
|
||||
"last_commit": "2026-10-09T03:19:47Z"
|
||||
},
|
||||
{
|
||||
"login": "onemannoplan",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T04:53:21Z",
|
||||
"last_commit": "2026-10-09T04:53:21Z"
|
||||
},
|
||||
{
|
||||
"login": "Yijie-Si",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 267,
|
||||
"lines_deleted": 43,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T06:48:00Z",
|
||||
"last_commit": "2026-10-09T06:48:00Z"
|
||||
},
|
||||
{
|
||||
"login": "HarrisXiu",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 69,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:34:51Z",
|
||||
"last_commit": "2026-10-09T09:34:51Z"
|
||||
},
|
||||
{
|
||||
"login": "rock-meister",
|
||||
"people_entry": true,
|
||||
"github_name": "rock-meister",
|
||||
"prs": 3,
|
||||
"commits": 4,
|
||||
"lines_added": 263,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T10:15:52Z",
|
||||
"last_commit": "2026-10-09T11:26:47Z"
|
||||
},
|
||||
{
|
||||
"login": "QuirkyTurtle94",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 153,
|
||||
"lines_deleted": 39,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T11:08:11Z",
|
||||
"last_commit": "2026-10-09T11:08:11Z"
|
||||
},
|
||||
{
|
||||
"login": "BuonBreda",
|
||||
"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": "cookingwithsimon",
|
||||
"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,
|
||||
"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": "henu043",
|
||||
"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,
|
||||
"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": "XRTce",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 475,
|
||||
"lines_added": 147839,
|
||||
"lines_deleted": 10702
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 116,
|
||||
"lines_added": 15071,
|
||||
"lines_deleted": 2624
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 65,
|
||||
"lines_added": 8385,
|
||||
"lines_deleted": 952
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 33,
|
||||
"lines_added": 14418,
|
||||
"lines_deleted": 1647
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 9,
|
||||
"lines_added": 1548,
|
||||
"lines_deleted": 112
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5",
|
||||
"commits": 2,
|
||||
"lines_added": 298,
|
||||
"lines_deleted": 2
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 1,
|
||||
"lines_added": 1633,
|
||||
"lines_deleted": 3
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-flash/v4",
|
||||
"company": "DeepSeek",
|
||||
"model": "DeepSeek Flash",
|
||||
"version": "V4",
|
||||
"commits": 1,
|
||||
"lines_added": 1439,
|
||||
"lines_deleted": 6
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek/v3.2",
|
||||
"company": "DeepSeek",
|
||||
"model": "DeepSeek",
|
||||
"version": "V3.2",
|
||||
"commits": 1,
|
||||
"lines_added": 1895,
|
||||
"lines_deleted": 294
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt/6",
|
||||
"company": "OpenAI",
|
||||
"model": "GPT",
|
||||
"version": "6",
|
||||
"commits": 1,
|
||||
"lines_added": 2864,
|
||||
"lines_deleted": 261
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rock-meister]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -155,7 +155,7 @@ impl Catalog {
|
||||
let date_of = |id: &PhotoId| -> String {
|
||||
let Some(p) = self.photo(*id) else { return String::new() };
|
||||
let d = match key {
|
||||
SortKey::CaptureDate => Some(p.date()),
|
||||
SortKey::CaptureDate => p.captured.as_deref(),
|
||||
SortKey::ImportDate => Some(p.imported.as_str()),
|
||||
SortKey::EditDate => p.edited.as_deref(),
|
||||
_ => None,
|
||||
@@ -222,7 +222,7 @@ mod tests {
|
||||
}
|
||||
let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::<Vec<_>>();
|
||||
let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day);
|
||||
// the undated photo falls back to its import date
|
||||
// the undated photo is grouped under Unknown Date
|
||||
assert_eq!(
|
||||
shape(&day),
|
||||
vec![
|
||||
@@ -230,16 +230,20 @@ mod tests {
|
||||
("2026-09-29".into(), 2, 1),
|
||||
("2026-08-01".into(), 3, 1),
|
||||
("2025-12-31".into(), 4, 1),
|
||||
("2026-10-01".into(), 5, 1)
|
||||
(String::new(), 5, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(day[0].label, "Wednesday, 30 September 2026");
|
||||
assert_eq!(day[4].label, "Unknown Date");
|
||||
assert_eq!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Auto), day);
|
||||
assert_eq!(
|
||||
shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Month)),
|
||||
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1)]
|
||||
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Month)),
|
||||
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1), (String::new(), 5, 1)]
|
||||
);
|
||||
assert_eq!(
|
||||
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Year)),
|
||||
vec![("2026".into(), 0, 4), ("2025".into(), 4, 1), (String::new(), 5, 1)]
|
||||
);
|
||||
assert_eq!(shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Year)), vec![("2026".into(), 0, 4), ("2025".into(), 4, 1)]);
|
||||
assert!(c.date_runs(&ids, SortKey::FileName, GroupBy::Day).is_empty());
|
||||
assert!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::None).is_empty());
|
||||
// edit date: unedited photos have no date
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! The library's folder tree: where on disk the imported photos live.
|
||||
//!
|
||||
//! A *folder* here is a folder holding at least one photo of the library (imported, not deleted,
|
||||
//! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the
|
||||
//! ones above them, never a folder that holds nothing of the library, with how many photos each
|
||||
//! holds — its subfolders included, like a year in By Date counts its months.
|
||||
//!
|
||||
//! Every folder is a row of its own, the ones above the imported photos included
|
||||
//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two
|
||||
//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see
|
||||
//! [`crate::query::folder_key`]).
|
||||
//!
|
||||
//! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share
|
||||
//! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`,
|
||||
//! `/mnt/disk`, `/run/user/1000/gvfs/smb-share:…`, `/net/host`) or, for everything else, the
|
||||
//! startup disk (`/`). Only the spelling of the stored paths tells them apart: no disk is
|
||||
//! touched, so a share that is asleep or gone lists and counts like one that is there, and the
|
||||
//! sidebar never waits on the network.
|
||||
//!
|
||||
//! Limits of reading only the stored spelling of a path: file names are compared as written
|
||||
//! (Windows folds case, other systems do not), so on a case-insensitive disk `/Volumes/tokyo` and
|
||||
//! `/Volumes/TOKYO` are two volumes here; and one place reached by two spellings
|
||||
//! (`/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.
|
||||
//!
|
||||
//! 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 crate::query::{folder_key, folder_within, split_path};
|
||||
use crate::{Catalog, 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)
|
||||
/// whatever a catalog holds.
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One row of the library's folder tree.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct FolderNode {
|
||||
/// What the row says: the folder's name; for a volume, its name (`nas`, `D:`,
|
||||
/// `//server/share`, `/` for the startup disk).
|
||||
pub name: String,
|
||||
/// The folder the row stands for, written out from the names of the photos' paths (forward
|
||||
/// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its
|
||||
/// photos.
|
||||
pub path: String,
|
||||
/// Photos in this folder or any folder inside it.
|
||||
pub count: usize,
|
||||
/// Photos directly in this folder.
|
||||
pub own: usize,
|
||||
/// A disk rather than a folder in one; its `path` is where it is mounted.
|
||||
pub volume: bool,
|
||||
/// Whether choosing the row (`Filter::library_folder`) shows exactly its `count` photos. Not
|
||||
/// 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,
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
/// A folder while the tree is being built (children by identity key).
|
||||
struct Level {
|
||||
name: String,
|
||||
path: String,
|
||||
own: usize,
|
||||
volume: bool,
|
||||
children: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where a stored folder path lies: its volume and the folder names below it.
|
||||
struct Placed {
|
||||
mount: String,
|
||||
volume: String,
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
/// A gvfs mount's name made readable: `smb-share:server=tokyo,share=photos` → `photos on tokyo`;
|
||||
/// any other (`sftp:host=nas`) as it is.
|
||||
fn mount_name(n: &str) -> String {
|
||||
let field = |key: &str| {
|
||||
let rest = n.strip_prefix("smb-share:")?;
|
||||
rest.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty())
|
||||
};
|
||||
match (field("share"), field("server")) {
|
||||
(Some(share), Some(server)) => format!("{share} on {server}"),
|
||||
_ => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume `dir` lies on and the folder names below it, read the way [`folder_key`] reads a
|
||||
/// path (so a row and the filter that chooses it never disagree). `None` for a path that is not
|
||||
/// absolute or names no share.
|
||||
fn place(dir: &str) -> Option<Placed> {
|
||||
let sp = split_path(dir, false);
|
||||
let names: Vec<&str> = sp.parts.iter().map(String::as_str).collect();
|
||||
let named = |mount: String, volume: &str, used: usize| (mount, volume.to_string(), used);
|
||||
let (mount, volume, used) = if sp.prefix.starts_with("//") {
|
||||
// `//server/share`: a server alone (no share) is no place to keep photos
|
||||
if sp.prefix.ends_with('/') {
|
||||
return None;
|
||||
}
|
||||
named(sp.prefix.clone(), &sp.prefix, 0)
|
||||
} else if !sp.prefix.is_empty() {
|
||||
named(sp.prefix.clone(), &sp.prefix, 0)
|
||||
} else if sp.absolute {
|
||||
match names.as_slice() {
|
||||
["Volumes", n, ..] => named(format!("/Volumes/{n}"), n, 2),
|
||||
["run", "media", u, n, ..] => named(format!("/run/media/{u}/{n}"), n, 4),
|
||||
["media", u, n, ..] => named(format!("/media/{u}/{n}"), n, 3),
|
||||
["mnt", n, ..] => named(format!("/mnt/{n}"), n, 2),
|
||||
// Linux desktops mount network places per user, naming server and share
|
||||
["run", "user", uid, "gvfs", n, ..] => named(format!("/run/user/{uid}/gvfs/{n}"), &mount_name(n), 5),
|
||||
// NFS automounts: Linux `/net/<host>`, macOS `/Network/Servers/<host>`
|
||||
["net", host, ..] => named(format!("/net/{host}"), host, 2),
|
||||
["Network", "Servers", host, ..] => named(format!("/Network/Servers/{host}"), host, 3),
|
||||
_ => named("/".to_string(), "/", 0),
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(Placed { mount, volume, names: names.iter().skip(used).take(MAX_DEPTH).map(|n| n.to_string()).collect() })
|
||||
}
|
||||
|
||||
/// The disk or share (`/Volumes/nas`, `D:`, `/` for the startup disk) a file-backed photo is on,
|
||||
/// from its stored path alone.
|
||||
pub fn volume_of(p: &crate::Photo) -> Option<String> {
|
||||
let Source::File { path } = &p.source else { return None };
|
||||
let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?;
|
||||
place(dir).map(|pl| folder_key(&pl.mount))
|
||||
}
|
||||
|
||||
/// Whether `path` is a whole disk or share (`/`, `C:\`, `\\srv\share`, `/Volumes/nas`), not a
|
||||
/// folder in one.
|
||||
pub fn is_disk_root(path: &str) -> bool {
|
||||
place(path).is_some_and(|p| p.names.is_empty())
|
||||
}
|
||||
|
||||
/// Whether `path` is the startup disk's root (`/`), whose path also covers every other disk.
|
||||
pub fn is_startup_disk(path: &str) -> bool {
|
||||
place(path).is_some_and(|p| p.names.is_empty() && p.mount == "/")
|
||||
}
|
||||
|
||||
/// A folder named by its last two names (`photos/travel`), so two folders called `travel` are
|
||||
/// told apart; the path itself when it has no names (`/`).
|
||||
pub fn folder_label(path: &str) -> String {
|
||||
let names: Vec<&str> = path.split(['/', '\\']).filter(|n| !n.is_empty()).collect();
|
||||
if names.is_empty() { path.to_string() } else { names.iter().skip(names.len().saturating_sub(2)).copied().collect::<Vec<_>>().join("/") }
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// 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> {
|
||||
// photos per containing folder, as spelled (sorted, so the spelling that names a folder
|
||||
// never depends on hash order)
|
||||
let mut dirs: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
if let Source::File { path } = &p.source
|
||||
&& let Some(i) = path.rfind(['/', '\\'])
|
||||
// a photo straight in the root has an empty folder part: the root itself
|
||||
&& let Some(dir) = path.get(..i.max(1))
|
||||
{
|
||||
*dirs.entry(dir).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut roots: Vec<String> = Vec::new();
|
||||
for (dir, n) in dirs {
|
||||
let Some(placed) = place(dir) else { continue };
|
||||
let vkey = folder_key(&placed.mount);
|
||||
if !levels.contains_key(&vkey) {
|
||||
levels.insert(
|
||||
vkey.clone(),
|
||||
Level { name: placed.volume.clone(), path: placed.mount.clone(), own: 0, volume: true, children: Vec::new() },
|
||||
);
|
||||
roots.push(vkey.clone());
|
||||
}
|
||||
let mut prefix = if placed.mount == "/" { String::new() } else { placed.mount.clone() };
|
||||
let mut parent = vkey;
|
||||
for name in &placed.names {
|
||||
prefix.push('/');
|
||||
prefix.push_str(name);
|
||||
let key = folder_key(&prefix);
|
||||
if !levels.contains_key(&key) {
|
||||
levels.insert(key.clone(), Level { name: name.clone(), path: prefix.clone(), own: 0, volume: false, children: Vec::new() });
|
||||
if let Some(l) = levels.get_mut(&parent) {
|
||||
l.children.push(key.clone());
|
||||
}
|
||||
}
|
||||
parent = key;
|
||||
}
|
||||
if let Some(l) = levels.get_mut(&parent) {
|
||||
l.own += n;
|
||||
}
|
||||
}
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).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();
|
||||
mark_selectable(&mut tree, &mounts);
|
||||
tree
|
||||
}
|
||||
}
|
||||
|
||||
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
|
||||
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
|
||||
let l = levels.get(key)?;
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).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 })
|
||||
}
|
||||
|
||||
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
|
||||
for n in nodes {
|
||||
n.selectable = !(n.volume && n.path == "/") && (n.volume || !mounts.iter().any(|m| folder_within(m, &n.path)));
|
||||
mark_selectable(&mut n.children, mounts);
|
||||
}
|
||||
}
|
||||
|
||||
fn sort(v: &mut [FolderNode]) {
|
||||
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)));
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 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` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -69,7 +70,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 = 2;
|
||||
pub const VERSION: u32 = 3;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dates;
|
||||
pub mod folders;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
@@ -29,13 +30,14 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use folders::FolderNode;
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
|
||||
pub use lock::{LibraryLock, LockError, LockOwner};
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::{FsStore, MemStore, Store};
|
||||
@@ -134,6 +136,11 @@ pub enum Op {
|
||||
id: AlbumId,
|
||||
parent: Option<AlbumId>,
|
||||
},
|
||||
/// Where the album stands among its siblings (`None`: by name). Format version 3.
|
||||
SetAlbumOrder {
|
||||
id: AlbumId,
|
||||
order: Option<u32>,
|
||||
},
|
||||
SetAlbumPhotos {
|
||||
id: AlbumId,
|
||||
photos: Vec<PhotoId>,
|
||||
@@ -280,6 +287,28 @@ impl Catalog {
|
||||
pub fn albums(&self) -> impl Iterator<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
/// The albums and folders directly inside `parent` (`None`: the top level), in the order the
|
||||
/// sidebar lists them: folders first, then by their place if the user ordered them (those
|
||||
/// without one after, by name), else by name.
|
||||
pub fn album_children(&self, parent: Option<AlbumId>) -> Vec<&Album> {
|
||||
let mut kids: Vec<&Album> = self.albums.values().filter(|a| a.parent == parent).collect();
|
||||
kids.sort_by_cached_key(|a| album_order_key(a));
|
||||
kids
|
||||
}
|
||||
/// [`Self::album_children`] of every folder (and of the top level, under `None`) in one pass:
|
||||
/// what a tree view that draws all of them wants each frame.
|
||||
pub fn album_children_by_parent(&self) -> std::collections::HashMap<Option<AlbumId>, Vec<&Album>> {
|
||||
let mut map: std::collections::HashMap<Option<AlbumId>, Vec<&Album>> = std::collections::HashMap::new();
|
||||
for a in self.albums.values() {
|
||||
map.entry(a.parent).or_default().push(a);
|
||||
}
|
||||
map.values_mut().for_each(|kids| kids.sort_by_cached_key(|a| album_order_key(a)));
|
||||
map
|
||||
}
|
||||
/// Whether anything inside `parent` has a place of its own (the folder is ordered by hand).
|
||||
pub fn album_children_are_ordered(&self, parent: Option<AlbumId>) -> bool {
|
||||
self.albums.values().any(|a| a.parent == parent && a.order.is_some())
|
||||
}
|
||||
/// The Quick Collection, once something was added to it.
|
||||
pub fn quick_collection(&self) -> Option<AlbumId> {
|
||||
self.albums.values().find(|a| a.quick).map(|a| a.id)
|
||||
@@ -477,7 +506,20 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
let a = self.album_mut(id)?;
|
||||
Op::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) }
|
||||
if a.parent == parent {
|
||||
return Ok(Op::MoveAlbum { id, parent });
|
||||
}
|
||||
// its place belonged to the old neighbours: the undo gives it back with the folder
|
||||
let old_order = a.order.take();
|
||||
let old_parent = std::mem::replace(&mut a.parent, parent);
|
||||
match old_order {
|
||||
Some(_) => Op::Batch { ops: vec![Op::MoveAlbum { id, parent: old_parent }, Op::SetAlbumOrder { id, order: old_order }] },
|
||||
None => Op::MoveAlbum { id, parent: old_parent },
|
||||
}
|
||||
}
|
||||
Op::SetAlbumOrder { id, order } => {
|
||||
let a = self.album_mut(id)?;
|
||||
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos } => {
|
||||
let a = self.album_mut(id)?;
|
||||
@@ -612,6 +654,22 @@ impl Catalog {
|
||||
Op::Batch { ops }
|
||||
}
|
||||
|
||||
/// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the
|
||||
/// catalog as it is now: albums lose all of them in one write each, and stacks are shortened
|
||||
/// (or dissolved) once, however many of their photos go.
|
||||
pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op {
|
||||
let gone: std::collections::HashSet<PhotoId> = ids.iter().copied().collect();
|
||||
let mut ops: Vec<Op> = self
|
||||
.albums
|
||||
.values()
|
||||
.filter(|a| a.photos.iter().any(|p| gone.contains(p)))
|
||||
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() })
|
||||
.collect();
|
||||
ops.extend(self.remove_from_stacks_ops(ids));
|
||||
ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id }));
|
||||
Op::Batch { ops }
|
||||
}
|
||||
|
||||
// ---- persistence
|
||||
|
||||
/// Full snapshot as JSON.
|
||||
@@ -650,11 +708,21 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// How siblings are listed: folders first, then the ones with a place of their own by it, then by
|
||||
/// name; the id settles any tie.
|
||||
fn album_order_key(a: &Album) -> (bool, bool, u32, String, AlbumId) {
|
||||
(!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
|
||||
@@ -295,6 +295,7 @@ impl Op {
|
||||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumOrder { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -183,6 +183,11 @@ pub struct Meta {
|
||||
pub copyright_url: String,
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
/// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo.
|
||||
/// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP.
|
||||
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub regions: Vec<lightcraft_meta::Region>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -331,6 +336,14 @@ 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).
|
||||
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))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
@@ -342,7 +355,8 @@ impl Photo {
|
||||
/// 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.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let mut d = match self.as_shot_wb {
|
||||
let wb = if self.relative_wb() { 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(),
|
||||
};
|
||||
@@ -357,7 +371,12 @@ impl Photo {
|
||||
}
|
||||
/// Edited by the user: settings differ from what import gave the photo.
|
||||
pub fn is_edited(&self) -> bool {
|
||||
*self.develop != self.import_defaults() && !self.develop.is_unedited()
|
||||
let mut defaults = self.import_defaults();
|
||||
// As Shot reads the source's current WB; stored numbers can predate a decoder update.
|
||||
if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot {
|
||||
defaults.wb = self.develop.wb;
|
||||
}
|
||||
*self.develop != defaults && !self.develop.is_unedited()
|
||||
}
|
||||
/// Capture time if known, else import time (sort key).
|
||||
pub fn date(&self) -> &str {
|
||||
@@ -386,12 +405,16 @@ pub struct Album {
|
||||
/// The Quick Collection: a regular album that B adds to (one per library).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub quick: bool,
|
||||
/// Its place among the albums of the same kind in its folder, once the user ordered them by
|
||||
/// hand (see [`crate::Catalog::album_children`]); `None`: listed by name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// A regular (manual) album.
|
||||
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false }
|
||||
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false, order: None }
|
||||
}
|
||||
pub fn is_smart(&self) -> bool {
|
||||
self.smart.is_some()
|
||||
@@ -415,4 +438,36 @@ mod edited_tests {
|
||||
p.develop = Arc::new(DevelopSettings::default());
|
||||
assert!(!p.is_edited(), "a full reset is unedited too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_raws_use_relative_white_balance() {
|
||||
for (name, format, relative) in [
|
||||
("a.arw", "ARW", true),
|
||||
("a.nef", "NEF", true),
|
||||
("a.nrw", "nrw", true),
|
||||
("a.rw2", "RW2", true),
|
||||
("a.rwl", "RWL", true),
|
||||
("a.raw", "RAW", true),
|
||||
("a.raf", "rAf", true),
|
||||
("a.cr3", "cr3", true),
|
||||
("a.srw", "SRW", 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.as_shot_wb = Some((5200.0, 4.0));
|
||||
assert_eq!(p.relative_wb(), 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}");
|
||||
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();
|
||||
custom.wb.mode = WbMode::Custom;
|
||||
p.develop = Arc::new(custom);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
-28
@@ -37,8 +37,14 @@ pub struct Filter {
|
||||
pub deleted: bool,
|
||||
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
|
||||
pub date: Option<String>,
|
||||
/// A folder the library's photos were imported from: those photos and the ones imported from
|
||||
/// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part
|
||||
/// of it, unlike [`Filter::folder`].
|
||||
pub library_folder: Option<String>,
|
||||
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
|
||||
pub keyword: Option<String>,
|
||||
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
|
||||
pub person: Option<String>,
|
||||
pub camera: Option<String>,
|
||||
/// Lens (case-insensitive substring).
|
||||
pub lens: Option<String>,
|
||||
@@ -70,6 +76,9 @@ pub enum SortKey {
|
||||
FileName,
|
||||
Rating,
|
||||
FileSize,
|
||||
/// A shuffle fixed by [`Sort::seed`]: the same seed always gives the same order, and photos
|
||||
/// added or edited later never reshuffle the others. Pick a new seed to reshuffle.
|
||||
Random,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -79,14 +88,30 @@ pub struct Sort {
|
||||
pub ascending: bool,
|
||||
/// Date headers in the grid (date sort keys only).
|
||||
pub group: crate::GroupBy,
|
||||
/// Which shuffle [`SortKey::Random`] gives (ignored by the other keys).
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for Sort {
|
||||
fn default() -> Self {
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto }
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// splitmix64 finaliser: a stateless, platform-independent mix (no RNG state, no `rand` version
|
||||
/// to drift), so a seed names the same shuffle on every machine.
|
||||
pub fn mix64(x: u64) -> u64 {
|
||||
let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A photo's place in the shuffle for `seed`.
|
||||
fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
|
||||
mix64(mix64(seed) ^ id.0)
|
||||
}
|
||||
|
||||
fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
let t = tok.to_lowercase();
|
||||
if let Some((field, val)) = t.split_once(':') {
|
||||
@@ -113,9 +138,10 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
"camera" => p.meta.camera.to_lowercase().contains(val),
|
||||
"lens" => p.meta.lens.to_lowercase().contains(val),
|
||||
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
|
||||
"person" | "who" => has_person(p, val),
|
||||
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
|
||||
"edited" => (val == "true" || val == "yes") == p.is_edited(),
|
||||
"date" => p.date().starts_with(val),
|
||||
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
|
||||
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
|
||||
"name" | "file" => p.file_name.to_lowercase().contains(val),
|
||||
_ => false,
|
||||
@@ -125,6 +151,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
|
||||
pub fn describe(&self) -> String {
|
||||
@@ -152,9 +184,15 @@ impl Filter {
|
||||
if let Some(e) = self.edited {
|
||||
v.push(if e { "edited".into() } else { "unedited".into() });
|
||||
}
|
||||
for (name, val) in
|
||||
[("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)]
|
||||
{
|
||||
for (name, val) in [
|
||||
("keyword", &self.keyword),
|
||||
("person", &self.person),
|
||||
("camera", &self.camera),
|
||||
("lens", &self.lens),
|
||||
("date", &self.date),
|
||||
("folder", &self.library_folder),
|
||||
("imported", &self.imported),
|
||||
] {
|
||||
if let Some(x) = val {
|
||||
v.push(format!("{name} {x}"));
|
||||
}
|
||||
@@ -180,6 +218,19 @@ impl Filter {
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
self.matches_in(p, cat, self.library_root().as_deref())
|
||||
}
|
||||
|
||||
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
|
||||
fn library_root(&self) -> Option<String> {
|
||||
// as given: a folder may be named with spaces at its edge, and trimming would make it
|
||||
// another folder
|
||||
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
|
||||
}
|
||||
|
||||
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
|
||||
/// over a big library does that once, not once per photo.
|
||||
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
@@ -233,12 +284,18 @@ impl Filter {
|
||||
return false;
|
||||
}
|
||||
if let Some(from) = &self.date_from
|
||||
&& p.date() < from.as_str()
|
||||
&& match p.captured.as_deref() {
|
||||
Some(c) => c < from.as_str(),
|
||||
None => true,
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(to) = &self.date_to
|
||||
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
|
||||
&& match p.captured.as_deref() {
|
||||
Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(),
|
||||
None => true,
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -248,7 +305,12 @@ impl Filter {
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = &self.date
|
||||
&& !p.date().starts_with(d.as_str())
|
||||
&& !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = root
|
||||
&& !photo_in_root(p, root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -267,6 +329,11 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(n) = &self.person
|
||||
&& !has_person(p, n)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(c) = &self.camera
|
||||
&& !p.meta.camera.eq_ignore_ascii_case(c)
|
||||
{
|
||||
@@ -279,15 +346,17 @@ impl Filter {
|
||||
impl Catalog {
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
|
||||
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect();
|
||||
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();
|
||||
v.sort_by(|a, b| {
|
||||
let o = match sort.key {
|
||||
SortKey::CaptureDate => a.date().cmp(b.date()),
|
||||
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::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)),
|
||||
}
|
||||
.then(a.id.cmp(&b.id));
|
||||
if sort.ascending { o } else { o.reverse() }
|
||||
@@ -300,7 +369,7 @@ impl Catalog {
|
||||
use std::collections::BTreeMap;
|
||||
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
let d = p.date();
|
||||
let Some(d) = p.captured.as_deref() else { continue };
|
||||
if d.len() >= 10 {
|
||||
let (months, days) = years.entry(&d[..4]).or_default();
|
||||
*months.entry(&d[..7]).or_default() += 1;
|
||||
@@ -330,6 +399,55 @@ impl Catalog {
|
||||
}
|
||||
m.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The people named on faces in the library (MWG regions read from XMP): how many photos each
|
||||
/// appears in and the photo showing their largest face (for a card's picture). Most photos first,
|
||||
/// then by name. Names that differ only in case are one person, shown as first seen; a person
|
||||
/// twice in one photo counts once.
|
||||
pub fn people(&self) -> Vec<Person> {
|
||||
self.people_in(&Filter::default())
|
||||
}
|
||||
|
||||
/// [`Self::people`] among the photos `filter` lets through (its `person` is ignored): what the
|
||||
/// People view offers while other filters (a date, a rating, an album…) are active, so picking
|
||||
/// a person never ends in an empty grid.
|
||||
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
|
||||
let filter = Filter { person: None, ..filter.clone() };
|
||||
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
|
||||
let root = filter.library_root();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for r in p.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 };
|
||||
let key = name.to_lowercase();
|
||||
// the face's size in pixels of the photo
|
||||
let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64;
|
||||
let (person, best) =
|
||||
m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area));
|
||||
if !seen.contains(&key) {
|
||||
person.count += 1;
|
||||
seen.push(key);
|
||||
}
|
||||
if area > *best || (area == *best && p.id < person.photo) {
|
||||
(person.photo, person.face, *best) = (p.id, r.rect, area);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Person> = m.into_values().map(|(p, _)| p).collect();
|
||||
v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// A person named on faces in the library ([`Catalog::people`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Person {
|
||||
pub name: String,
|
||||
/// Photos they appear in.
|
||||
pub count: usize,
|
||||
/// The photo with their largest face, and that face (normalized, in the photo's upright frame).
|
||||
pub photo: PhotoId,
|
||||
pub face: lightcraft_meta::Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
@@ -351,12 +469,18 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`,
|
||||
/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute.
|
||||
pub(crate) struct SplitPath {
|
||||
pub prefix: String,
|
||||
pub parts: Vec<String>,
|
||||
pub absolute: bool,
|
||||
}
|
||||
|
||||
/// Both separators, repeated separators (also between a server and its share), `.` and a
|
||||
/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed;
|
||||
/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is).
|
||||
pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath {
|
||||
let mut s = path.replace('\\', "/");
|
||||
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
|
||||
s = match rest.strip_prefix("UNC/") {
|
||||
@@ -364,28 +488,39 @@ pub fn folder_key(path: &str) -> String {
|
||||
None => rest.to_string(),
|
||||
};
|
||||
}
|
||||
// the part `..` can't climb above: `//server/share`, `c:` or `/`
|
||||
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
|
||||
let mut it = unc.splitn(3, '/');
|
||||
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), rest.to_string())
|
||||
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
|
||||
(s[..2].to_ascii_lowercase(), s[2..].to_string())
|
||||
let mut segs = unc.split('/').filter(|x| !x.is_empty());
|
||||
let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), segs.collect::<Vec<_>>().join("/"))
|
||||
} else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) {
|
||||
(if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string())
|
||||
} else {
|
||||
(String::new(), s.clone())
|
||||
};
|
||||
let absolute = rest.starts_with('/') || !prefix.is_empty();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for c in rest.split('/') {
|
||||
match c {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
".." if parts.last().is_some_and(|p| p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
".." if absolute => {}
|
||||
c => parts.push(c),
|
||||
c => parts.push(c.to_string()),
|
||||
}
|
||||
}
|
||||
SplitPath { prefix, parts, absolute }
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere
|
||||
/// (macOS, a Samba share) two spellings that differ in case are two folders here.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
let SplitPath { prefix, parts, absolute } = split_path(path, true);
|
||||
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
|
||||
if cfg!(windows) { key.to_lowercase() } else { key }
|
||||
}
|
||||
@@ -393,8 +528,51 @@ pub fn folder_key(path: &str) -> String {
|
||||
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
|
||||
/// (see [`folder_key`]).
|
||||
pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
let (p, r) = (folder_key(path), folder_key(root));
|
||||
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
|
||||
key_within(&folder_key(path), &folder_key(root))
|
||||
}
|
||||
|
||||
/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
|
||||
/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
|
||||
/// the names keep their case, which the key lowers on Windows.
|
||||
pub fn folder_rest(path: &str, root: &str) -> Option<Vec<String>> {
|
||||
if !key_within(&folder_key(path), &folder_key(root)) {
|
||||
return None;
|
||||
}
|
||||
let depth = split_path(root, false).parts.len();
|
||||
Some(split_path(path, false).parts.into_iter().skip(depth).collect())
|
||||
}
|
||||
|
||||
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
|
||||
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
|
||||
/// separators) is its own identity, so it is read without building a key for it: the check runs
|
||||
/// for every photo of the library on each refresh, and this keeps it cheap.
|
||||
pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool {
|
||||
if p.local {
|
||||
return false;
|
||||
}
|
||||
let crate::Source::File { path } = &p.source else { return false };
|
||||
let plain = path.starts_with('/')
|
||||
&& !path.starts_with("//")
|
||||
&& !path.ends_with('/')
|
||||
&& !path.contains(['\\'])
|
||||
&& !path.contains("//")
|
||||
&& !path.contains("/.");
|
||||
if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") {
|
||||
let r = root.trim_end_matches('/');
|
||||
return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'));
|
||||
}
|
||||
crate::local::folder_of(p).is_some_and(|f| key_within(&f, root))
|
||||
}
|
||||
|
||||
/// [`folder_within`] for two paths already turned into their [`folder_key`].
|
||||
pub(crate) fn key_within(p: &str, r: &str) -> bool {
|
||||
// `.` or `a/..` name no folder: not "everything"
|
||||
if r.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder
|
||||
let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/'));
|
||||
p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Catalog, Photo};
|
||||
use crate::{Catalog, Photo, Source};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -62,6 +62,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
|
||||
("keywords", "Keywords", Kind::Keywords),
|
||||
("text", "Any Searchable Text", Kind::Text),
|
||||
("fileName", "Filename", Kind::Text),
|
||||
("filePath", "File Path", Kind::Text),
|
||||
("format", "File Format", Kind::Text),
|
||||
("title", "Title", Kind::Text),
|
||||
("caption", "Caption", Kind::Text),
|
||||
@@ -304,6 +305,20 @@ impl Rule {
|
||||
text(&all)
|
||||
}
|
||||
"fileName" => text(&p.file_name),
|
||||
"filePath" => {
|
||||
// `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path
|
||||
let path = match &p.source {
|
||||
Source::File { path } => path.replace('\\', "/"),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
let want = want.replace('\\', "/");
|
||||
match op {
|
||||
// the whole string, spaces included (not word by word like the other text fields)
|
||||
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
|
||||
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
|
||||
_ => text_op(op, &path, &want),
|
||||
}
|
||||
}
|
||||
"format" => text(&p.format),
|
||||
"title" => text(&m.title),
|
||||
"caption" => text(&m.caption),
|
||||
@@ -445,6 +460,17 @@ mod tests {
|
||||
);
|
||||
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
|
||||
// file path: the whole string (spaces included), either separator, case-insensitive
|
||||
let mut fp = photo(3);
|
||||
fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() };
|
||||
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]})));
|
||||
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]})));
|
||||
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]})));
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
|
||||
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
|
||||
no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path
|
||||
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
|
||||
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
//! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and
|
||||
//! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]).
|
||||
//!
|
||||
//! **Write policy** (issue #134): what can't be recreated — originals, XMP sidecars, the catalog,
|
||||
//! settings, DNGs that replace raws, smart previews (the only stand-in while an original is
|
||||
//! offline), an edit copy that becomes a library photo — is written **durably**: synced to disk
|
||||
//! before it counts as written. What can always be made again from the originals — exports,
|
||||
//! renders, screenshots, merge previews — is written **atomically** only: a half-written file
|
||||
//! never replaces a good one, but there is no per-file sync (on a USB drive or a NAS that sync
|
||||
//! dominates a large export).
|
||||
//!
|
||||
//! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive
|
||||
//! either the old or the complete new content is there — never a truncated mix. The content
|
||||
//! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and
|
||||
//! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp
|
||||
//! file and leaves the target as it was.
|
||||
//! - [`write_atomic_nosync`] is the same temp file + rename without the two syncs, for
|
||||
//! recreatable outputs only: a failed write still removes the temp file and leaves the target as
|
||||
//! it was, but after a power cut the new file may be lost (on some file systems left empty) —
|
||||
//! it is then simply exported again.
|
||||
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
|
||||
//! and sync, plus a read-back check, and the file then appears under its final name **without
|
||||
//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
|
||||
@@ -21,6 +33,19 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
thread_local! {
|
||||
static FAIL_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
|
||||
static SYNCS: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many file and folder syncs this module made **on the calling thread** (for tests: a
|
||||
/// durable write path syncs, a recreatable output doesn't).
|
||||
#[doc(hidden)]
|
||||
pub fn syncs_on_this_thread() -> u64 {
|
||||
SYNCS.with(Cell::get)
|
||||
}
|
||||
|
||||
fn sync_file(f: &File) -> io::Result<()> {
|
||||
SYNCS.with(|c| c.set(c.get() + 1));
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Fault injection for tests (in any crate): while the returned guard lives, every write made by
|
||||
@@ -78,7 +103,7 @@ fn parent_of(path: &Path) -> &Path {
|
||||
/// where NTFS journals the rename itself, so errors are ignored.
|
||||
pub fn sync_dir(dir: &Path) {
|
||||
if let Ok(d) = File::open(dir) {
|
||||
let _ = d.sync_all();
|
||||
let _ = sync_file(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +125,9 @@ fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display())))
|
||||
}
|
||||
|
||||
/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp
|
||||
/// file is removed. Returns its path and length.
|
||||
fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
|
||||
/// Write a temp file next to `path` with what `fill` writes, synced to disk when `sync`. On
|
||||
/// failure the temp file is removed. Returns its path and length.
|
||||
fn write_temp(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> {
|
||||
let (tmp, file) = create_temp(path)?;
|
||||
let r = (|| {
|
||||
let limit = FAIL_AFTER.with(Cell::get);
|
||||
@@ -111,7 +136,9 @@ fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()
|
||||
w.flush()?;
|
||||
let n = w.n;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
f.sync_all()?;
|
||||
if sync {
|
||||
sync_file(&f)?;
|
||||
}
|
||||
Ok(n)
|
||||
})();
|
||||
match r {
|
||||
@@ -132,12 +159,26 @@ pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
/// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old
|
||||
/// file). Returns the length written.
|
||||
pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let (tmp, n) = write_temp(path, fill)?;
|
||||
replace_with(path, true, fill)
|
||||
}
|
||||
|
||||
/// Replace (or create) `path` with `data` atomically but **without syncing** it to disk — only for
|
||||
/// outputs that can always be recreated (exports, renders, screenshots); see the module docs.
|
||||
/// Anything that can't be made again goes through [`write_atomic`].
|
||||
pub fn write_atomic_nosync(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
replace_with(path, false, &mut |w| w.write_all(data)).map(|_| ())
|
||||
}
|
||||
|
||||
/// Temp file (synced when `sync`) renamed over `path`, then the folder synced when `sync`.
|
||||
fn replace_with(path: &Path, sync: bool, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<u64> {
|
||||
let (tmp, n) = write_temp(path, sync, fill)?;
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(parent_of(path));
|
||||
if sync {
|
||||
sync_dir(parent_of(path));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -155,8 +196,11 @@ pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u
|
||||
let Some(first) = candidates.peek().cloned() else {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write"));
|
||||
};
|
||||
let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?;
|
||||
let (tmp, _) = write_temp(&first, true, &mut |w| w.write_all(data))?;
|
||||
let r = (|| {
|
||||
// Kept (issue #134 point 4): these files replace raws (Convert / Copy as DNG) or become
|
||||
// library photos (merges). On a local disk the read usually comes from the cache, but on a
|
||||
// network volume it can go back to the server, and next to encoding a DNG it costs little.
|
||||
if !same_content(&tmp, data)? {
|
||||
return Err(io::Error::other("the written file reads back different (failing drive or connection?)"));
|
||||
}
|
||||
@@ -287,6 +331,33 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreatable_outputs_are_atomic_without_a_sync() {
|
||||
let d = temp("nosync");
|
||||
let p = d.join("export.jpg");
|
||||
let before = syncs_on_this_thread();
|
||||
write_atomic_nosync(&p, b"one").unwrap();
|
||||
write_atomic_nosync(&p, b"two two").unwrap();
|
||||
assert_eq!(syncs_on_this_thread(), before, "no file or folder sync");
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two two");
|
||||
assert_eq!(names(&d), vec!["export.jpg"], "no temp file left");
|
||||
// still atomic: a failure part-way leaves the previous file and no temp file
|
||||
{
|
||||
let _f = fail_writes_after(3);
|
||||
assert!(write_atomic_nosync(&p, &[9u8; 10_000]).is_err());
|
||||
}
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two two");
|
||||
assert_eq!(names(&d), vec!["export.jpg"]);
|
||||
// the durable writers do sync (the file; the folder too where it can be opened)
|
||||
let before = syncs_on_this_thread();
|
||||
write_atomic(&p, b"three").unwrap();
|
||||
let mid = syncs_on_this_thread();
|
||||
assert!(mid > before);
|
||||
write_new(&d.join("new.dng"), b"dng").unwrap();
|
||||
assert!(syncs_on_this_thread() > mid);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_never_replace_anything() {
|
||||
let d = temp("new");
|
||||
|
||||
+206
-2
@@ -58,12 +58,22 @@ fn albums_and_folders() {
|
||||
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
||||
let f = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false },
|
||||
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false, order: None },
|
||||
})
|
||||
.unwrap();
|
||||
let al = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum {
|
||||
album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false },
|
||||
album: Album {
|
||||
id: al,
|
||||
name: "Alps".into(),
|
||||
parent: Some(f),
|
||||
folder: false,
|
||||
photos: vec![a],
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(c.albums_of(a), vec![al]);
|
||||
@@ -297,3 +307,197 @@ fn folder_identity_ignores_spelling() {
|
||||
assert!(folder_within("/a/b", "/"));
|
||||
assert!(folder_within("C:\\x", "c:\\"));
|
||||
}
|
||||
|
||||
/// People come from named *face* regions: counted once per photo, case-insensitive, most photos
|
||||
/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match.
|
||||
#[test]
|
||||
fn people_from_named_face_regions() {
|
||||
use lightcraft_meta::{Rect, Region, RegionKind};
|
||||
let region = |name: Option<&str>, kind: RegionKind| Region {
|
||||
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
|
||||
kind,
|
||||
name: name.map(str::to_string),
|
||||
description: None,
|
||||
};
|
||||
let mut c = Catalog::new();
|
||||
let mut add = |name: &str, regions: Vec<Region>| {
|
||||
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.regions = regions;
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let a = add(
|
||||
"a.jpg",
|
||||
vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)],
|
||||
);
|
||||
let b =
|
||||
add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]);
|
||||
let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 };
|
||||
let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]);
|
||||
let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]);
|
||||
add("f.jpg", vec![]);
|
||||
let people = c.people();
|
||||
let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect();
|
||||
assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out");
|
||||
// the picture is the person's largest face; ties go to the lower photo id
|
||||
assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }));
|
||||
assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one");
|
||||
|
||||
let q = |f: Filter| c.query(&f, &Sort::default());
|
||||
assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2);
|
||||
let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() });
|
||||
got.sort();
|
||||
assert_eq!(got, vec![b, d]);
|
||||
assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person");
|
||||
assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case");
|
||||
assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a));
|
||||
|
||||
// other filters narrow who is offered (so picking a person never ends in an empty grid); the
|
||||
// `person` filter itself does not
|
||||
c.apply(Op::SetRating { id: b, rating: 3 }).unwrap();
|
||||
let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::<Vec<_>>();
|
||||
let rated = Filter { rating: 1, ..Default::default() };
|
||||
assert_eq!(
|
||||
names(rated.clone()),
|
||||
vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)],
|
||||
"only the rated photo's people, counted within it, spelled as first seen there"
|
||||
);
|
||||
assert_eq!(
|
||||
names(Filter { person: Some("Sam".into()), ..rated }),
|
||||
names(Filter { rating: 1, ..Default::default() }),
|
||||
"the person filter is ignored"
|
||||
);
|
||||
assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos");
|
||||
}
|
||||
|
||||
/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written
|
||||
/// before regions existed (no `regions` key) reads with none.
|
||||
#[test]
|
||||
fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
let m = Meta { keywords: vec!["k".into()], ..Default::default() };
|
||||
let v = serde_json::to_value(&m).unwrap();
|
||||
assert!(v.get("regions").is_none(), "{v}");
|
||||
let back: Meta = serde_json::from_value(v).unwrap();
|
||||
assert!(back.regions.is_empty());
|
||||
let mut with = m.clone();
|
||||
with.regions.push(lightcraft_meta::Region {
|
||||
rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
|
||||
kind: lightcraft_meta::RegionKind::Face,
|
||||
name: Some("A".into()),
|
||||
description: None,
|
||||
});
|
||||
let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
|
||||
|
||||
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
|
||||
(0..n).map(|i| photo(c, &format!("p{i:03}.jpg"), &format!("2026-04-{:02}T10:00:00", i % 28 + 1))).collect()
|
||||
}
|
||||
|
||||
fn random(seed: u64) -> Sort {
|
||||
Sort { key: SortKey::Random, seed, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_is_a_reproducible_permutation() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 50);
|
||||
let a = c.query(&Filter::default(), &random(7));
|
||||
assert_eq!(a, c.query(&Filter::default(), &random(7)), "same seed, same order");
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort();
|
||||
let mut all = ids.clone();
|
||||
all.sort();
|
||||
assert_eq!(sorted, all, "every photo exactly once");
|
||||
assert_ne!(a, c.query(&Filter::default(), &random(8)), "another seed, another order");
|
||||
assert_ne!(a, c.query(&Filter::default(), &Sort::default()), "not just the date order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_keeps_the_relative_order_when_the_set_changes() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 30);
|
||||
let before = c.query(&Filter::default(), &random(3));
|
||||
let added = photo(&mut c, "new.jpg", "2026-05-01T10:00:00");
|
||||
c.apply(Op::SetRating { id: ids[0], rating: 5 }).unwrap();
|
||||
let after: Vec<PhotoId> = c.query(&Filter::default(), &random(3)).into_iter().filter(|id| *id != added).collect();
|
||||
assert_eq!(after, before, "adding a photo or editing metadata must not reshuffle the others");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_respects_the_filter_and_handles_tiny_sets() {
|
||||
let mut c = Catalog::new();
|
||||
assert!(c.query(&Filter::default(), &random(1)).is_empty());
|
||||
let ids = many(&mut c, 10);
|
||||
c.apply(Op::SetRating { id: ids[4], rating: 5 }).unwrap();
|
||||
assert_eq!(c.query(&Filter { rating: 5, ..Default::default() }, &random(1)), vec![ids[4]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_direction_reverses_and_serde_defaults_hold() {
|
||||
let mut c = Catalog::new();
|
||||
many(&mut c, 20);
|
||||
let desc = c.query(&Filter::default(), &Sort { ascending: false, ..random(5) });
|
||||
let mut asc = c.query(&Filter::default(), &Sort { ascending: true, ..random(5) });
|
||||
asc.reverse();
|
||||
assert_eq!(desc, asc);
|
||||
// saved sorts from before the seed existed still load
|
||||
let old: Sort = serde_json::from_str(r#"{"key":"fileName","ascending":true}"#).unwrap();
|
||||
assert_eq!(old.seed, 0);
|
||||
assert_eq!(serde_json::from_str::<Sort>(r#"{"key":"random","seed":9}"#).unwrap(), Sort { key: SortKey::Random, seed: 9, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_sort_has_no_date_headers() {
|
||||
let mut c = Catalog::new();
|
||||
let ids = many(&mut c, 5);
|
||||
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undated_photos_group_under_unknown_date_and_sort_together() {
|
||||
let mut c = Catalog::new();
|
||||
let add_photo = |c: &mut Catalog, name: &str, captured: Option<&str>, imported: &str| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 0 }, name, "JPEG", 100, 100, imported);
|
||||
p.captured = captured.map(str::to_string);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let p_dated1 = add_photo(&mut c, "dated1.jpg", Some("2026-05-10T12:00:00"), "2026-10-01T00:00:00");
|
||||
let p_dated2 = add_photo(&mut c, "dated2.jpg", Some("2026-05-20T12:00:00"), "2026-10-01T00:00:00");
|
||||
let p_undated1 = add_photo(&mut c, "undated1.jpg", None, "2026-10-05T00:00:00");
|
||||
let p_undated2 = add_photo(&mut c, "undated2.jpg", None, "2026-10-02T00:00:00");
|
||||
|
||||
// Descending sort (default, newest first): dated photos first (newest to oldest), undated photos at the end
|
||||
let desc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() });
|
||||
assert_eq!(desc, vec![p_dated2, p_dated1, p_undated1, p_undated2]);
|
||||
|
||||
// Ascending sort (oldest first): undated photos at the beginning, dated photos from oldest to newest
|
||||
let asc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() });
|
||||
assert_eq!(asc, vec![p_undated2, p_undated1, p_dated1, p_dated2]);
|
||||
|
||||
// date_runs groups undated photos under Unknown Date
|
||||
let runs = c.date_runs(&desc, SortKey::CaptureDate, GroupBy::Day);
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert_eq!(runs[0].key, "2026-05-20");
|
||||
assert_eq!(runs[1].key, "2026-05-10");
|
||||
assert_eq!(runs[2].key, "");
|
||||
assert_eq!(runs[2].label, "Unknown Date");
|
||||
assert_eq!(runs[2].count, 2);
|
||||
|
||||
// date_groups sidebar tree only counts photos with capture dates
|
||||
let groups = c.date_groups();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].year, "2026");
|
||||
assert_eq!(groups[0].count, 2);
|
||||
|
||||
// Filter by capture date prefix does not match undated photos
|
||||
let filter_day = Filter { date: Some("2026-10-05".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_day, &Sort::default()), vec![]);
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Album order: the children of a folder (or of the top level) are listed folders first, then
|
||||
//! alphabetically, until some of them are given a place of their own (`Album.order`); moving an
|
||||
//! album to another folder drops its place, and undoing the move brings it back.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, name: &str, parent: Option<AlbumId>, folder: bool) -> AlbumId {
|
||||
let id = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album { parent, folder, ..Album::new(id, name) } }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn names(c: &Catalog, parent: Option<AlbumId>) -> Vec<String> {
|
||||
c.album_children(parent).iter().map(|a| a.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn place(c: &mut Catalog, id: AlbumId, order: Option<u32>) {
|
||||
c.apply(Op::SetAlbumOrder { id, order }).unwrap();
|
||||
}
|
||||
|
||||
/// Given albums that were never ordered, when they are listed, then folders come first and each
|
||||
/// group is alphabetical regardless of case.
|
||||
#[test]
|
||||
fn children_are_folders_first_then_alphabetical() {
|
||||
let mut c = Catalog::new();
|
||||
add(&mut c, "beta", None, false);
|
||||
add(&mut c, "Alpha", None, false);
|
||||
add(&mut c, "zoo", None, true);
|
||||
add(&mut c, "Archive", None, true);
|
||||
assert_eq!(names(&c, None), ["Archive", "zoo", "Alpha", "beta"]);
|
||||
assert!(!c.album_children_are_ordered(None));
|
||||
}
|
||||
|
||||
/// Given some children with a place of their own, then they follow it; the others come after, by
|
||||
/// name; folders stay ahead of albums.
|
||||
#[test]
|
||||
fn ordered_children_follow_their_order() {
|
||||
let mut c = Catalog::new();
|
||||
let a = add(&mut c, "A", None, false);
|
||||
let b = add(&mut c, "B", None, false);
|
||||
let cc = add(&mut c, "C", None, false);
|
||||
add(&mut c, "D", None, false);
|
||||
let f = add(&mut c, "F", None, true);
|
||||
place(&mut c, cc, Some(0));
|
||||
place(&mut c, a, Some(1));
|
||||
assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]);
|
||||
assert!(c.album_children_are_ordered(None));
|
||||
// equal places fall back to the name, so a damaged catalog still lists deterministically
|
||||
place(&mut c, b, Some(1));
|
||||
assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]);
|
||||
// another container is unaffected
|
||||
let inner = add(&mut c, "Z", Some(f), false);
|
||||
add(&mut c, "Y", Some(f), false);
|
||||
assert_eq!(names(&c, Some(f)), ["Y", "Z"]);
|
||||
assert!(!c.album_children_are_ordered(Some(f)));
|
||||
place(&mut c, inner, Some(0));
|
||||
assert_eq!(names(&c, Some(f)), ["Z", "Y"]);
|
||||
}
|
||||
|
||||
/// The one-pass map lists every folder's children exactly as `album_children` does.
|
||||
#[test]
|
||||
fn the_children_map_matches_the_children_of_each_folder() {
|
||||
let mut c = Catalog::new();
|
||||
let f = add(&mut c, "F", None, true);
|
||||
let g = add(&mut c, "G", Some(f), true);
|
||||
for (name, parent) in [("b", None), ("a", None), ("y", Some(f)), ("x", Some(f)), ("q", Some(g))] {
|
||||
add(&mut c, name, parent, false);
|
||||
}
|
||||
let x = c.albums().find(|a| a.name == "x").unwrap().id;
|
||||
place(&mut c, x, Some(7));
|
||||
let map = c.album_children_by_parent();
|
||||
for parent in [None, Some(f), Some(g)] {
|
||||
let from_map: Vec<AlbumId> = map.get(&parent).map(|v| v.iter().map(|a| a.id).collect()).unwrap_or_default();
|
||||
let direct: Vec<AlbumId> = c.album_children(parent).iter().map(|a| a.id).collect();
|
||||
assert_eq!(from_map, direct, "{parent:?}");
|
||||
}
|
||||
assert!(!map.contains_key(&Some(AlbumId(999))));
|
||||
}
|
||||
|
||||
/// A place is one undo step, and `None` puts the album back among the alphabetical ones.
|
||||
#[test]
|
||||
fn a_place_can_be_set_cleared_and_undone() {
|
||||
let mut c = Catalog::new();
|
||||
let a = add(&mut c, "A", None, false);
|
||||
add(&mut c, "B", None, false);
|
||||
let inv = c.apply(Op::SetAlbumOrder { id: a, order: Some(5) }).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, Some(5));
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, None);
|
||||
assert!(c.apply(Op::SetAlbumOrder { id: AlbumId(999), order: Some(1) }).is_err(), "no such album");
|
||||
}
|
||||
|
||||
/// Moving to another folder drops the album's place there (it belonged to the old neighbours),
|
||||
/// and undoing the move gives it back.
|
||||
#[test]
|
||||
fn moving_drops_the_place_and_undo_restores_it() {
|
||||
let mut c = Catalog::new();
|
||||
let from = add(&mut c, "From", None, true);
|
||||
let to = add(&mut c, "To", None, true);
|
||||
let a = add(&mut c, "A", Some(from), false);
|
||||
place(&mut c, a, Some(3));
|
||||
let inv = c.apply(Op::MoveAlbum { id: a, parent: Some(to) }).unwrap();
|
||||
assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(to), None));
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(from), Some(3)));
|
||||
// "moving" to where it already is changes nothing
|
||||
c.apply(Op::MoveAlbum { id: a, parent: Some(from) }).unwrap();
|
||||
assert_eq!(c.album(a).unwrap().order, Some(3));
|
||||
}
|
||||
|
||||
/// Catalogs written before albums had an order load as they were; an unset order is not written.
|
||||
#[test]
|
||||
fn albums_without_an_order_load_and_save_unchanged() {
|
||||
let old = r#"{"id":4,"name":"Old","parent":null,"folder":false,"photos":[],"cover":null}"#;
|
||||
let a: Album = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(a.order, None);
|
||||
assert!(!serde_json::to_string(&a).unwrap().contains("order"));
|
||||
let mut a = a;
|
||||
a.order = Some(2);
|
||||
let back: Album = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(back.order, Some(2));
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The library's folder tree: where the imported photos live on disk (see [`crate::folders`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone browsing their library:
|
||||
//!
|
||||
//! * Given photos imported from several folders, the tree lists those folders and no others,
|
||||
//! each with how many photos it holds, under the disk (volume) they are on.
|
||||
//! * A folder's count includes the folders inside it.
|
||||
//! * Every folder is a row of its own, the ones above the imported photos included.
|
||||
//! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere.
|
||||
//! * However a folder is spelled, it is one folder.
|
||||
//! * Choosing a folder shows what was imported from it and from the folders inside it — exactly
|
||||
//! as many photos as the row says.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, path: &str) -> PhotoId {
|
||||
add_with(c, path, |_| {})
|
||||
}
|
||||
|
||||
fn add_with(c: &mut Catalog, path: &str, tweak: impl FnOnce(&mut Photo)) -> PhotoId {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
tweak(&mut p);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn library(paths: &[&str]) -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
paths.iter().for_each(|p| {
|
||||
add(&mut c, p);
|
||||
});
|
||||
c
|
||||
}
|
||||
|
||||
/// `(name, count, own)` of each node, depth first.
|
||||
fn outline(nodes: &[FolderNode]) -> Vec<(String, usize, usize)> {
|
||||
fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) {
|
||||
out.push((n.name.clone(), n.count, n.own));
|
||||
n.children.iter().for_each(|c| walk(c, out));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
nodes.iter().for_each(|n| walk(n, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
fn row(name: &str, count: usize, own: usize) -> (String, usize, usize) {
|
||||
(name.to_string(), count, own)
|
||||
}
|
||||
|
||||
fn rows(paths: &[&str]) -> Vec<(String, usize, usize)> {
|
||||
outline(&library(paths).folder_tree())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_has_no_folders() {
|
||||
assert!(Catalog::new().folder_tree().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_folders_with_imported_photos_are_listed_with_their_counts() {
|
||||
let c = library(&["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(outline(&tree), vec![row("/", 3, 0), row("pics", 3, 0), row("home", 1, 1), row("trip", 2, 2)]);
|
||||
assert!(tree[0].volume && !tree[0].children[0].volume);
|
||||
assert_eq!(tree[0].children[0].children[1].path, "/pics/trip", "a row knows its folder, for choosing it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folders_count_includes_the_folders_inside_it() {
|
||||
let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]);
|
||||
assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() {
|
||||
let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(
|
||||
outline(&tree),
|
||||
vec![
|
||||
row("/", 2, 0),
|
||||
row("Users", 2, 0),
|
||||
row("me", 2, 0),
|
||||
row("Pictures", 2, 0),
|
||||
row("2024", 1, 1),
|
||||
row("2025", 1, 1),
|
||||
row("nas", 1, 0),
|
||||
row("photos", 1, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_with_photos_of_its_own_is_not_folded_into_its_child() {
|
||||
assert_eq!(rows(&["/pics/a.jpg", "/pics/trip/b.jpg"]), vec![row("/", 2, 0), row("pics", 2, 1), row("trip", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() {
|
||||
let mut c = Catalog::new();
|
||||
add(&mut c, "/pics/kept/a.jpg");
|
||||
add_with(&mut c, "/pics/browsed/b.jpg", |p| p.local = true);
|
||||
add_with(&mut c, "/pics/gone/c.jpg", |p| p.deleted = true);
|
||||
let id = c.alloc_photo_id();
|
||||
let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap();
|
||||
assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn however_a_folder_is_spelled_it_is_one_folder() {
|
||||
let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]);
|
||||
assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_disk_is_a_volume_of_its_own() {
|
||||
// macOS, Linux and Windows habits for where disks appear
|
||||
let r = rows(&["/Volumes/nas/p/1.jpg", "/media/me/usb/2.jpg", "/run/media/me/card/3.jpg", "/mnt/disk/4.jpg"]);
|
||||
assert_eq!(r, vec![row("card", 1, 1), row("disk", 1, 1), row("nas", 1, 0), row("p", 1, 1), row("usb", 1, 1)]);
|
||||
let r = rows(&["D:\\Photos\\2024\\a.jpg", "D:\\Photos\\2025\\b.jpg", "C:\\x\\c.jpg"]);
|
||||
assert_eq!(r, vec![row("C:", 1, 0), row("x", 1, 1), row("D:", 2, 0), row("Photos", 2, 0), row("2024", 1, 1), row("2025", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_share_is_a_volume_and_two_spellings_are_one() {
|
||||
let r = rows(&["\\\\srv\\s1\\a\\1.jpg", "\\\\srv\\s2\\b\\2.jpg"]);
|
||||
assert_eq!(r, vec![row("//srv/s1", 1, 0), row("a", 1, 1), row("//srv/s2", 1, 0), row("b", 1, 1)]);
|
||||
// the verbatim form Windows APIs give is the same share
|
||||
let r = rows(&["\\\\?\\UNC\\srv\\s1\\a\\1.jpg", "\\\\srv\\s1\\a\\2.jpg"]);
|
||||
assert_eq!(r, vec![row("//srv/s1", 2, 0), row("a", 2, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_samba_share_mounted_on_a_mac_is_a_volume_named_after_the_share() {
|
||||
// Finder mounts every share under /Volumes, and a second mount of the same share gets a suffix
|
||||
let r = rows(&["/Volumes/tokyo/Photos/2024/a.jpg", "/Volumes/tokyo/Photos/2025/b.jpg", "/Volumes/tokyo-1/old/c.jpg", "/Volumes/東京 NAS/d.jpg"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
row("tokyo", 2, 0),
|
||||
row("Photos", 2, 0),
|
||||
row("2024", 1, 1),
|
||||
row("2025", 1, 1),
|
||||
row("tokyo-1", 1, 0),
|
||||
row("old", 1, 1),
|
||||
row("東京 NAS", 1, 1)
|
||||
]
|
||||
);
|
||||
let tree = library(&["/Volumes/tokyo/Photos/a.jpg"]).folder_tree();
|
||||
assert_eq!((tree[0].path.as_str(), tree[0].volume), ("/Volumes/tokyo", true), "choosing the share shows everything on it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shares_the_other_systems_mount_are_volumes_too() {
|
||||
// Linux desktops: gvfs mounts per user, with the server and share in the mount's name
|
||||
let gvfs = "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos";
|
||||
let r = rows(&[&format!("{gvfs}/2024/a.jpg"), "/run/user/1000/gvfs/sftp:host=nas/b.jpg"]);
|
||||
assert_eq!(r, vec![row("photos on tokyo", 1, 0), row("2024", 1, 1), row("sftp:host=nas", 1, 1)]);
|
||||
// NFS automounts: Linux /net/<host>, macOS /Network/Servers/<host>
|
||||
let r = rows(&["/net/tokyo/export/a.jpg", "/Network/Servers/osaka/vol/b.jpg"]);
|
||||
assert_eq!(r, vec![row("osaka", 1, 0), row("vol", 1, 1), row("tokyo", 1, 0), row("export", 1, 1)]);
|
||||
// Windows: a share by name or by address, the WSL file system, a mapped drive
|
||||
let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
row("//192.168.1.5/scans", 1, 1),
|
||||
row("//tokyo.local/photos", 1, 1),
|
||||
row("//wsl$/Ubuntu", 1, 0),
|
||||
row("home", 1, 0),
|
||||
row("me", 1, 1),
|
||||
row("Z:", 1, 1)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_that_is_not_mounted_costs_nothing() {
|
||||
// the tree is made from the catalog's own records: it never looks at a disk, so a share that
|
||||
// is asleep or gone lists (and counts) exactly like one that is there
|
||||
let c = library(&["/Volumes/definitely-not-mounted/a/b.jpg", r"\\unreachable.invalid\share\c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(tree.iter().map(|n| n.count).sum::<usize>(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_in_the_root_of_a_disk_counts_for_the_disk() {
|
||||
assert_eq!(rows(&["/x.jpg", "/d/y.jpg"]), vec![row("/", 2, 1), row("d", 1, 1)]);
|
||||
assert_eq!(rows(&["C:\\x.jpg"]), vec![row("C:", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_with_no_folder_are_left_out() {
|
||||
assert!(rows(&["x.jpg", ""]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_listed_by_name_ignoring_case() {
|
||||
let names: Vec<String> = rows(&["/p/b/1.jpg", "/p/A/2.jpg", "/p/c/3.jpg"]).into_iter().skip(2).map(|r| r.0).collect();
|
||||
assert_eq!(names, ["A", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurdly_deep_paths_neither_crash_nor_lose_photos() {
|
||||
let deep = format!("/{}/a.jpg", vec!["d"; 5000].join("/"));
|
||||
let tree = library(&[&deep]).folder_tree();
|
||||
assert_eq!(tree.iter().map(|n| n.count).sum::<usize>(), 1);
|
||||
assert!(outline(&tree).len() <= crate::folders::MAX_DEPTH + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_shows_what_was_imported_from_it_and_below() {
|
||||
let mut c = Catalog::new();
|
||||
let trip = add(&mut c, "/pics/trip/a.jpg");
|
||||
let day = add(&mut c, "/pics/trip/day1/b.jpg");
|
||||
let sibling = add(&mut c, "/pics/trip2/c.jpg"); // shares the name's start, not the folder
|
||||
let browsed = add_with(&mut c, "/pics/trip/d.jpg", |p| p.local = true);
|
||||
let f = Filter { library_folder: Some("/pics//trip/".into()), ..Default::default() };
|
||||
let shown = |id: PhotoId| f.matches(c.photo(id).unwrap(), &c);
|
||||
assert!(shown(trip) && shown(day));
|
||||
assert!(!shown(sibling), "/pics/trip2 is another folder");
|
||||
assert!(!shown(browsed), "browsed photos are not part of the library's folders");
|
||||
assert_eq!(f.describe(), "folder /pics//trip/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
|
||||
let mut c = Catalog::new();
|
||||
let gone = add_with(&mut c, "/pics/trip/a.jpg", |p| p.deleted = true);
|
||||
let kept = add(&mut c, "/pics/trip/b.jpg");
|
||||
let f = Filter { library_folder: Some("/pics/trip".into()), deleted: true, ..Default::default() };
|
||||
assert!(f.matches(c.photo(gone).unwrap(), &c));
|
||||
assert!(!f.matches(c.photo(kept).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_folder_choice_is_no_choice() {
|
||||
let c = library(&["/pics/a.jpg"]);
|
||||
let f = Filter { library_folder: Some(" ".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_shows_exactly_as_many_photos_as_it_says() {
|
||||
let c = library(&[
|
||||
"/Users/me/Pictures/2024/a.jpg",
|
||||
"/Users/me/Pictures/2024/b.jpg",
|
||||
"/Users/me/Pictures/2025/c.jpg",
|
||||
"/Users/me/Pictures/d.jpg",
|
||||
"/Volumes/nas/photos/e.jpg",
|
||||
"/Volumes/nas/f.jpg",
|
||||
"/x.jpg",
|
||||
"/a/../b/g.jpg",
|
||||
"D:\\Photos\\2024\\h.jpg",
|
||||
"d:/Photos/i.jpg",
|
||||
"\\\\srv\\s1\\a\\j.jpg",
|
||||
"\\\\?\\UNC\\srv\\s2\\k.jpg",
|
||||
"/pics/trip/l.jpg",
|
||||
"/pics/trip2/m.jpg",
|
||||
"/Volumes/tokyo/Photos/2024/n.jpg",
|
||||
"/Volumes/tokyo/o.jpg",
|
||||
"/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/p.jpg",
|
||||
"/net/osaka/export/q.jpg",
|
||||
r"\\wsl$\Ubuntu\home\me\r.jpg",
|
||||
// odd spellings the tree and the filter must read the same way
|
||||
"//server/share/../other/1.jpg",
|
||||
"//server/share/ok/2.jpg",
|
||||
"//a b//A/f.jpg",
|
||||
r"\\wsl$\Ubuntu\..\a\1.jpg",
|
||||
// a folder that holds other disks as well as photos of its own
|
||||
"/Volumes/1.jpg",
|
||||
"/mnt/2.jpg",
|
||||
"/mnt/disk/3.jpg",
|
||||
]);
|
||||
fn check(c: &Catalog, nodes: &[FolderNode], seen: &mut usize) {
|
||||
for n in nodes {
|
||||
// the startup volume's row only opens: its path "/" would cover every other disk
|
||||
if n.selectable {
|
||||
let f = Filter { library_folder: Some(n.path.clone()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), n.count, "{} ({})", n.name, n.path);
|
||||
*seen += 1;
|
||||
}
|
||||
check(c, &n.children, seen);
|
||||
}
|
||||
}
|
||||
let mut seen = 0;
|
||||
check(&c, &c.folder_tree(), &mut seen);
|
||||
assert!(seen > 10, "{seen} rows checked");
|
||||
}
|
||||
|
||||
fn find<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
|
||||
nodes.iter().find_map(|n| if n.path == path { Some(n) } else { find(&n.children, path) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_would_cover_other_disks_is_not_offered_for_choosing() {
|
||||
let tree = library(&["/Users/me/a.jpg", "/Volumes/1.jpg", "/Volumes/tokyo/x.jpg", "/mnt/2.jpg"]).folder_tree();
|
||||
let sel = |p: &str| find(&tree, p).map(|n| n.selectable);
|
||||
assert_eq!(sel("/"), Some(false), "the startup disk's path covers every disk");
|
||||
assert_eq!(sel("/Volumes"), Some(false), "it holds the tokyo disk");
|
||||
assert_eq!(sel("/Volumes/tokyo"), Some(true));
|
||||
assert_eq!(sel("/Users/me"), Some(true));
|
||||
assert_eq!(sel("/mnt"), Some(true), "no disk is mounted below it in this library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_and_the_filter_read_odd_spellings_the_same_way() {
|
||||
// `..` cannot climb above a share, and repeated separators between server and share are one
|
||||
let tree = library(&["//server/share/../other/1.jpg", "//a b//A/f.jpg"]).folder_tree();
|
||||
assert_eq!(outline(&tree), vec![row("//a b/A", 1, 1), row("//server/share", 1, 0), row("other", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_chooses_nothing() {
|
||||
let c = library(&["/pics/a.jpg", "C:\\b\\c.jpg"]);
|
||||
for dot in [".", "./", "a/..", "../.."] {
|
||||
let f = Filter { library_folder: Some(dot.into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), 0, "{dot:?} is no folder, not everything");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_labelled_by_its_last_two_names() {
|
||||
use crate::folders::folder_label;
|
||||
assert_eq!(folder_label("/Volumes/tokyo/photos/travel"), "photos/travel");
|
||||
assert_eq!(folder_label("/Volumes/tokyo/"), "Volumes/tokyo");
|
||||
assert_eq!(folder_label(r"C:\Photos\2024"), "Photos/2024");
|
||||
assert_eq!(folder_label("/"), "/");
|
||||
assert_eq!(folder_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_roots_are_told_from_folders() {
|
||||
use crate::folders::{is_disk_root, is_startup_disk};
|
||||
for p in ["/", "C:", "C:\\", r"\\?\C:\", r"\\srv\share", "/Volumes/nas", "/mnt/disk", "/net/host"] {
|
||||
assert!(is_disk_root(p), "{p}");
|
||||
}
|
||||
for p in ["/Volumes", "/Users", "C:\\Photos", r"\\srv\share\x", "/Volumes/nas/p", "."] {
|
||||
assert!(!is_disk_root(p), "{p}");
|
||||
}
|
||||
assert!(is_startup_disk("/") && is_startup_disk("//") && !is_startup_disk("/Volumes/nas") && !is_startup_disk("C:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_with_spaces_at_its_edge_is_its_own_folder() {
|
||||
// legal on macOS and Linux: `/p/shoot ` is not `/p/shoot`
|
||||
let c = library(&["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
let shoot_space = find(&tree, "/p/shoot ").expect("its own row");
|
||||
let shoot = find(&tree, "/p/shoot").expect("and the other");
|
||||
for (node, want) in [(shoot_space, "a.jpg"), (shoot, "b.jpg")] {
|
||||
let f = Filter { library_folder: Some(node.path.clone()), ..Default::default() };
|
||||
let ids = c.query(&f, &Sort::default());
|
||||
let names: Vec<&str> = ids.iter().filter_map(|id| c.photo(*id)).map(|p| p.file_name.as_str()).collect();
|
||||
assert_eq!(names, [want], "{:?} shows its own photo", node.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_photo_never_counts_as_imported_even_when_a_disk_folder_is_also_shown() {
|
||||
let mut c = Catalog::new();
|
||||
let browsed = add_with(&mut c, "/pics/trip/b.jpg", |p| p.local = true);
|
||||
let f = Filter { folder: Some("/pics/trip".into()), library_folder: Some("/pics/trip".into()), ..Default::default() };
|
||||
assert!(!f.matches(c.photo(browsed).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_check_agrees_with_the_careful_one() {
|
||||
use crate::query::{folder_key, key_within, photo_in_root};
|
||||
let paths = [
|
||||
"/a/b/c.jpg",
|
||||
"/a/b/d/e.jpg",
|
||||
"/a/bc/x.jpg",
|
||||
"/a/./b/x.jpg",
|
||||
"/a/../a/b/x.jpg",
|
||||
"/a//b/x.jpg",
|
||||
"/a\\b\\x.jpg",
|
||||
"/a/b ",
|
||||
"/a/b /x.jpg",
|
||||
"/.hidden/x.jpg",
|
||||
"/a/.b/x.jpg",
|
||||
"/x.jpg",
|
||||
"//srv/share/x.jpg",
|
||||
"C:\\a\\b\\x.jpg",
|
||||
"relative/a/b/x.jpg",
|
||||
"/Volumes/tokyo/a/x.jpg",
|
||||
"/日本/写真/x.jpg",
|
||||
];
|
||||
let roots = ["/", "/a", "/a/b", "/a/b/", "/a/bc", "/a/b ", "/Volumes/tokyo", "//srv/share", "C:\\a", "/日本", "relative", "/a/../a/b"];
|
||||
for p in paths {
|
||||
let mut c = Catalog::new();
|
||||
let id = add(&mut c, p);
|
||||
let photo = c.photo(id).unwrap();
|
||||
for r in roots {
|
||||
let root = folder_key(r);
|
||||
let slow = crate::local::folder_of(photo).is_some_and(|f| key_within(&f, &root));
|
||||
assert_eq!(photo_in_root(photo, &root), slow, "{p:?} in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,19 @@ 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.
|
||||
#[test]
|
||||
fn v2_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":2,\"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(2)));
|
||||
assert_eq!(snapshot_version(&m), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
@@ -179,8 +192,9 @@ fn op_variants_are_versioned() {
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
Op::SetAlbumOrder { .. } => 3,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
|
||||
let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -59,6 +59,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||
cover: None,
|
||||
smart: None,
|
||||
quick: false,
|
||||
order: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -77,6 +78,7 @@ 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 == 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") } }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-heif = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -34,6 +35,10 @@ ravif = { workspace = true, optional = true }
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
# 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"]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
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
|
||||
|
||||
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
|
||||
@@ -24,6 +25,12 @@ encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
```
|
||||
|
||||
Thumbnail fallback decoding limits the source to 64 million pixels by default.
|
||||
Non-JPEG decoders allocate at source resolution before resizing, even for a tiny
|
||||
thumbnail. Use `decode_thumbnail_with` and `ThumbnailOptions::max_pixels` to set
|
||||
a different source budget. Oversized sources return `Error::TooLarge`; embedded
|
||||
JPEG previews can still be used without decoding the full source image.
|
||||
|
||||
`Format::RawTiffLike` (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, …) and `Format::RawOther` (CR3, RAF,
|
||||
CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `decode` returns
|
||||
`Error::Unsupported` for them.
|
||||
@@ -50,6 +57,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (
|
||||
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
|
||||
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
|
||||
|
||||
`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour
|
||||
space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light
|
||||
resizing; a JPEG carrying its own metadata retains its existing interpretation.
|
||||
|
||||
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
|
||||
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
|
||||
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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`].
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
use crate::convert::{Buf, Meta, Model, Raw, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[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(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::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
let info = lightcraft_heif::probe(bytes).map_err(err)?;
|
||||
if (info.width as u64).saturating_mul(info.height as u64) > opts.max_pixels {
|
||||
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));
|
||||
}
|
||||
Buf::U16(v)
|
||||
} else {
|
||||
Buf::U8(decoded.data)
|
||||
};
|
||||
let raw = Raw {
|
||||
width: decoded.width as usize,
|
||||
height: decoded.height as usize,
|
||||
model: Model::Rgb,
|
||||
alpha: decoded.has_alpha,
|
||||
premultiplied: false,
|
||||
buf,
|
||||
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Format, sniff};
|
||||
|
||||
/// The container header both builds recognise, whatever the feature says.
|
||||
const HEIC_HEADER: &[u8] = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
|
||||
#[test]
|
||||
fn heif_is_always_recognised() {
|
||||
assert_eq!(sniff(HEIC_HEADER), Some(Format::Heif));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_decode_follows_the_feature() {
|
||||
assert_eq!(Format::Heif.can_decode(), cfg!(feature = "heif"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
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:?}");
|
||||
}
|
||||
|
||||
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
|
||||
/// out of range (`boxes.rs:130`). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50,
|
||||
0x72, 0x6d, 0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70,
|
||||
0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
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:?}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
|
||||
};
|
||||
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
|
||||
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
|
||||
align_tags(&p.encode().unwrap_or_default())
|
||||
align_tags(&fixed_date(p.encode().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend
|
||||
/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode`
|
||||
/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in
|
||||
/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers
|
||||
/// do not interpret the field; all zeros is the conventional "unspecified" value.
|
||||
fn fixed_date(mut b: Vec<u8>) -> Vec<u8> {
|
||||
if let Some(date) = b.get_mut(24..36) {
|
||||
date.fill(0);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
|
||||
@@ -300,6 +312,23 @@ mod tests {
|
||||
let _ = SRGB;
|
||||
}
|
||||
|
||||
/// Two profiles for one space are byte-identical, and the header date is fixed: exports of the
|
||||
/// same photo with the same settings hash the same (#180).
|
||||
#[test]
|
||||
fn written_profiles_are_deterministic() {
|
||||
for n in NamedSpace::ALL {
|
||||
let a = write_named(n);
|
||||
let b = write_named(n);
|
||||
assert_eq!(a, b, "{n:?}");
|
||||
assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time");
|
||||
// the profile ID is "not computed", not a hash of a timestamp
|
||||
assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID");
|
||||
}
|
||||
let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear);
|
||||
assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear));
|
||||
assert_eq!(&lin[24..36], &[0u8; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_none() {
|
||||
assert!(parse(&[]).is_none());
|
||||
|
||||
+138
-2
@@ -105,12 +105,67 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
|
||||
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
|
||||
/// type or component count, or when its markers don't run to an end-of-image marker after the
|
||||
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
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.sof, 0xC0..=0xC3) {
|
||||
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
|
||||
}
|
||||
if !matches!(m.components, 1 | 3 | 4) {
|
||||
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
|
||||
}
|
||||
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
|
||||
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
|
||||
}
|
||||
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((m.width, m.height, orientation))
|
||||
}
|
||||
|
||||
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
|
||||
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
|
||||
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
|
||||
/// markers that end it.
|
||||
fn reaches_eoi(b: &[u8]) -> bool {
|
||||
let mut p = 2usize;
|
||||
loop {
|
||||
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
|
||||
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
|
||||
p = p.saturating_add(skip);
|
||||
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
|
||||
p = match marker {
|
||||
0xD9 => return true,
|
||||
0xFF => p.saturating_add(1),
|
||||
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
|
||||
_ => {
|
||||
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
|
||||
return false;
|
||||
};
|
||||
if len < 2 {
|
||||
return false;
|
||||
}
|
||||
p.saturating_add(2).saturating_add(len)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
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, opts)?;
|
||||
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh));
|
||||
let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
|
||||
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
|
||||
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
|
||||
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
|
||||
@@ -119,7 +174,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
} else {
|
||||
decode_zune(bytes, &m)?
|
||||
};
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() };
|
||||
// A raw container may carry the colour declaration of an otherwise untagged preview.
|
||||
// Apply it before linearization/resizing; existing JPEG metadata always wins.
|
||||
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
|
||||
finish(F, raw, meta, (m.width, m.height), opts)
|
||||
}
|
||||
|
||||
@@ -168,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
|
||||
}
|
||||
|
||||
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
|
||||
/// is most of [`header`]'s time).
|
||||
fn find_ff(b: &[u8]) -> Option<usize> {
|
||||
const ONES: u64 = 0x0101_0101_0101_0101;
|
||||
let (words, tail) = b.as_chunks::<8>();
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
// a zero byte in !w is an 0xFF byte in w
|
||||
let x = !u64::from_ne_bytes(*w);
|
||||
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
|
||||
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
|
||||
}
|
||||
}
|
||||
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
|
||||
}
|
||||
|
||||
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
|
||||
/// fitted with the source aspect ratio.
|
||||
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
|
||||
@@ -246,12 +319,75 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() {
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg};
|
||||
let pixels: Vec<u8> = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect();
|
||||
let encode = |icc, exif| {
|
||||
encode_jpeg(
|
||||
&EncodeImage::new(16, 16, 3, Samples::U8(&pixels)),
|
||||
100,
|
||||
ChromaSubsampling::S444,
|
||||
&EncodeMeta { icc, exif, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let untagged = encode(None, None);
|
||||
let adobe = crate::icc::write_named(NamedSpace::AdobeRgb);
|
||||
let tagged = encode(Some(&adobe), None);
|
||||
for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] {
|
||||
let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&tagged, opts).unwrap();
|
||||
assert_eq!(with.space.origin, SpaceOrigin::Container);
|
||||
let (a, b) = (with.to_working(), expected.to_working());
|
||||
for (p, q) in a.data.iter().zip(&b.data) {
|
||||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}");
|
||||
}
|
||||
}
|
||||
let srgb = crate::icc::write_named(NamedSpace::Srgb);
|
||||
let exif = crate::exif::minimal_exif(6);
|
||||
for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] {
|
||||
let opts = DecodeOptions::fit(5, 5);
|
||||
let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&bytes, opts).unwrap();
|
||||
assert_eq!(with.space, expected.space);
|
||||
assert_eq!(with.image, expected.image);
|
||||
assert_eq!(with.orientation, expected.orientation);
|
||||
}
|
||||
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_request_covers() {
|
||||
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
|
||||
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ff_matches_a_plain_search() {
|
||||
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
|
||||
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
|
||||
if let Some(i) = at {
|
||||
b[i] = 0xFF;
|
||||
}
|
||||
for start in [0, 1, 5, 9, 290, 299, 300] {
|
||||
let s = &b[start..];
|
||||
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_fit_box_decodes_at_full_size() {
|
||||
// a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling,
|
||||
// and no overflow working out whether to scale
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
let pixels = vec![128u8; 32 * 24 * 3];
|
||||
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap();
|
||||
assert_eq!((d.width, d.height), (32, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_on_garbage() {
|
||||
assert!(parse_markers(b"").is_none());
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
|
||||
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
|
||||
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
|
||||
//! - [`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
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
@@ -17,6 +19,7 @@
|
||||
mod convert;
|
||||
pub mod encode;
|
||||
pub mod exif;
|
||||
mod heif;
|
||||
pub mod icc;
|
||||
mod jpeg;
|
||||
pub mod jpeg_par;
|
||||
@@ -156,6 +159,60 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub format: Format,
|
||||
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
|
||||
pub orientation: u16,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
|
||||
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
|
||||
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
|
||||
/// third-party parser is caught on unwinding targets).
|
||||
pub fn read_header(bytes: &[u8]) -> Result<Header> {
|
||||
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
|
||||
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
|
||||
}
|
||||
|
||||
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
|
||||
let (width, height, orientation) = match format {
|
||||
Format::Jpeg => jpeg::header(bytes)?,
|
||||
Format::Png => png_codec::header(bytes)?,
|
||||
Format::Tiff => tiff_codec::header(bytes)?,
|
||||
Format::WebP => webp::header(bytes)?,
|
||||
Format::Psd => psd::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))?;
|
||||
(d.source_width, d.source_height, d.orientation)
|
||||
}
|
||||
};
|
||||
Ok(Header { format, width, height, orientation })
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback)))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// [`decode`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
@@ -171,8 +228,8 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
||||
Format::Jxl => jxl::decode(bytes, &opts),
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
|
||||
Format::Heif => heif::decode(bytes, &opts),
|
||||
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
|
||||
Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")),
|
||||
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
|
||||
}
|
||||
}
|
||||
@@ -217,11 +274,19 @@ pub struct ThumbnailOptions {
|
||||
/// smaller size). Defaults to `max_edge`, i.e. only previews that need no upscaling. Lower it
|
||||
/// (e.g. 160 for typical EXIF thumbnails) for an instant placeholder.
|
||||
pub min_embedded_edge: u32,
|
||||
/// Maximum source pixels for fallback decoding (default 64 MP). Non-JPEG formats
|
||||
/// decode at source resolution before resizing, so output size does not bound memory.
|
||||
#[serde(default = "thumbnail_max_pixels")]
|
||||
pub max_pixels: u64,
|
||||
}
|
||||
|
||||
fn thumbnail_max_pixels() -> u64 {
|
||||
64_000_000
|
||||
}
|
||||
|
||||
impl ThumbnailOptions {
|
||||
pub fn new(max_edge: u32) -> Self {
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge }
|
||||
ThumbnailOptions { max_edge, min_embedded_edge: max_edge, max_pixels: thumbnail_max_pixels() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +305,7 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
let d = decode(bytes, DecodeOptions::fit(max_edge, max_edge))?;
|
||||
let d = decode(bytes, DecodeOptions { max_size: Some((max_edge, max_edge)), max_pixels: opts.max_pixels })?;
|
||||
Ok(Thumbnail {
|
||||
image: d.to_srgb8(),
|
||||
orientation: d.orientation,
|
||||
|
||||
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
|
||||
/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
|
||||
/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
|
||||
/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
|
||||
pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
|
||||
if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
|
||||
return Err(err("missing IHDR"));
|
||||
}
|
||||
let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
|
||||
let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
|
||||
let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
|
||||
if w > i32::MAX as u32 || h > i32::MAX as u32 {
|
||||
return Err(err("dimensions out of range"));
|
||||
}
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let depth_ok = match color {
|
||||
0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
|
||||
3 => matches!(depth, 1 | 2 | 4 | 8),
|
||||
2 | 4 | 6 => matches!(depth, 8 | 16),
|
||||
_ => false,
|
||||
};
|
||||
if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
|
||||
return Err(err("invalid IHDR"));
|
||||
}
|
||||
let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
|
||||
while let Some(len) = be32(p) {
|
||||
let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
|
||||
if idat && kind != b"IDAT" {
|
||||
// a chunk follows the image data: the IDAT run is complete
|
||||
after_idat = true;
|
||||
}
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
let start = p.saturating_add(8);
|
||||
let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
|
||||
let Some(data) = data else {
|
||||
if after_idat {
|
||||
break; // a damaged chunk after the image data: a decode ignores it too
|
||||
}
|
||||
return Err(err("truncated"));
|
||||
};
|
||||
match kind {
|
||||
b"PLTE" => palette = true,
|
||||
b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
|
||||
b"IDAT" => {
|
||||
if color == 3 && !palette {
|
||||
return Err(err("indexed image without a palette"));
|
||||
}
|
||||
idat = true;
|
||||
}
|
||||
b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
|
||||
_ => {}
|
||||
}
|
||||
p = start.saturating_add(data.len()).saturating_add(4);
|
||||
}
|
||||
if !idat {
|
||||
return Err(err("no image data"));
|
||||
}
|
||||
if !after_idat {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
if let Some(c) = &info.coding_independent_code_points {
|
||||
let named = match c.color_primaries {
|
||||
|
||||
+265
-72
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (icc, exif, xmp) = resources(res);
|
||||
|
||||
// Layer and mask information: only the layer count sign matters (merged transparency).
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
let lm = r.take(lm_len)?;
|
||||
let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
|
||||
let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
|
||||
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
|
||||
let off = if psb { 8 } else { 4 };
|
||||
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
|
||||
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let plane = width * height * bpc;
|
||||
let row_bytes = width * bpc;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
|
||||
match compression {
|
||||
0 => {
|
||||
for _ in 0..channels {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let counts_len = channels * height;
|
||||
let mut counts = Vec::with_capacity(counts_len);
|
||||
for _ in 0..counts_len {
|
||||
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
for c in 0..channels {
|
||||
let mut out = Vec::with_capacity(plane);
|
||||
for y in 0..height {
|
||||
let src = r.take(counts[c * height + y])?;
|
||||
unpackbits(src, row_bytes, &mut out);
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
|
||||
let (model, n_color) = match mode {
|
||||
1 | 8 => (Model::Gray, 1),
|
||||
2 => (Model::Rgb, 1),
|
||||
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
let alpha = merged_transparency && channels > n_color && mode != 2;
|
||||
// Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
|
||||
// stored after them and never touched.
|
||||
let used = n_color + alpha as usize;
|
||||
let n = width * height;
|
||||
let too_large = || Error::TooLarge(width as u64, height as u64);
|
||||
let n = width.checked_mul(height).ok_or_else(too_large)?;
|
||||
let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(used);
|
||||
match compression {
|
||||
0 => {
|
||||
// Raw planes are borrowed from the input, so their size is bounded by the file.
|
||||
for _ in 0..used {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Row byte-count table: one entry per row per channel (all channels, since the data
|
||||
// follows it). Taken from the input before anything is allocated from it.
|
||||
let entry = if psb { 4 } else { 2 };
|
||||
let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
|
||||
let table = r.take(table_len)?;
|
||||
let count = |i: usize| -> usize {
|
||||
let at = i.saturating_mul(entry);
|
||||
match table.get(at..at.saturating_add(entry)) {
|
||||
Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
|
||||
Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
// PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
|
||||
// at least this many encoded bytes. Requiring it (and the data being present) before
|
||||
// allocating bounds the decoded planes by the file size: a tiny file can no longer
|
||||
// declare gigabytes of zero-padded or repeat-run rows.
|
||||
let min_row = row_bytes.div_ceil(128).saturating_mul(2);
|
||||
let mut data_len = 0usize;
|
||||
for i in 0..used.saturating_mul(height) {
|
||||
let c = count(i);
|
||||
if c < min_row {
|
||||
return Err(err("RLE row too short for its width"));
|
||||
}
|
||||
data_len = data_len.saturating_add(c);
|
||||
}
|
||||
if data_len > bytes.len().saturating_sub(r.p) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
for c in 0..used {
|
||||
let mut out = zeroed::<u8>(plane, too_large)?;
|
||||
for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
|
||||
let src = r.take(count(c * height + y))?;
|
||||
unpackbits(src, row)?;
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
let samples = n.checked_mul(used).ok_or_else(too_large)?;
|
||||
|
||||
// Interleave to device samples.
|
||||
let buf = if mode == 2 {
|
||||
// Indexed → 8-bit RGB via the 768-byte palette.
|
||||
if color_mode_data.len() < 768 || depth != 8 {
|
||||
let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let mut v = Vec::with_capacity(n * 3);
|
||||
for &i in &planes[0] {
|
||||
};
|
||||
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
|
||||
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
|
||||
for (o, &i) in v.as_chunks_mut::<3>().0.iter_mut().zip(idx) {
|
||||
let i = i as usize;
|
||||
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
|
||||
*o = [pal[i], pal[256 + i], pal[512 + i]];
|
||||
}
|
||||
Buf::U8(v)
|
||||
} else {
|
||||
let cmyk = model == Model::Cmyk;
|
||||
match bpc {
|
||||
1 => {
|
||||
let mut v = vec![0u8; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = p[i];
|
||||
let mut v = zeroed::<u8>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
|
||||
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
|
||||
*o = if cmyk && c < 4 { 255 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U8(v)
|
||||
}
|
||||
2 => {
|
||||
let mut v = vec![0u16; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
|
||||
let mut v = zeroed::<u16>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<2>().0) {
|
||||
let x = u16::from_be_bytes(*s);
|
||||
*o = if cmyk && c < 4 { 65535 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U16(v)
|
||||
}
|
||||
_ => {
|
||||
let mut v = vec![0f32; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
|
||||
let mut v = zeroed::<f32>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<4>().0) {
|
||||
let x = f32::from_be_bytes(*s);
|
||||
*o = if cmyk && c < 4 { 1.0 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::F32(v)
|
||||
@@ -182,25 +211,111 @@ 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)
|
||||
}
|
||||
|
||||
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
|
||||
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
|
||||
let target = out.len() + row_bytes;
|
||||
while out.len() < target && !src.is_empty() {
|
||||
let h = src[0] as i8;
|
||||
src = &src[1..];
|
||||
if h >= 0 {
|
||||
let n = (h as usize + 1).min(src.len()).min(target - out.len());
|
||||
out.extend_from_slice(&src[..n]);
|
||||
src = &src[(h as usize + 1).min(src.len())..];
|
||||
} else if h != -128 {
|
||||
let n = ((1 - h as isize) as usize).min(target - out.len());
|
||||
if let Some(&b) = src.first() {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
src = &src[1..];
|
||||
/// 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
|
||||
/// (truncated). The run-length data itself is not unpacked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut r = Rd { b: bytes, p: 0 };
|
||||
if r.take(4)? != b"8BPS" {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let psb = match r.u16()? {
|
||||
1 => false,
|
||||
2 => true,
|
||||
_ => return Err(err("bad version")),
|
||||
};
|
||||
r.take(6)?;
|
||||
let channels = r.u16()? as usize;
|
||||
let height = r.u32()? as usize;
|
||||
let width = r.u32()? as usize;
|
||||
let depth = r.u16()?;
|
||||
let mode = r.u16()?;
|
||||
check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
|
||||
if channels == 0 || channels > 56 {
|
||||
return Err(err("bad channel count"));
|
||||
}
|
||||
let cm_len = r.u32()? as usize;
|
||||
let color_mode_data = r.take(cm_len)?;
|
||||
let res_len = r.u32()? as usize;
|
||||
let (_, exif, _) = resources(r.take(res_len)?);
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
r.take(lm_len)?;
|
||||
let compression = r.u16()?;
|
||||
let bpc = match depth {
|
||||
8 => 1usize,
|
||||
16 => 2,
|
||||
32 => 4,
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let n_color = match mode {
|
||||
1 | 8 | 7 | 2 => 1,
|
||||
3 => 3,
|
||||
4 => 4,
|
||||
9 => return Err(Error::Unsupported(F, "Lab PSD")),
|
||||
_ => return Err(err("unknown colour mode")),
|
||||
};
|
||||
if channels < n_color {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let rest = bytes.len().saturating_sub(r.p);
|
||||
let need = match compression {
|
||||
0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
|
||||
1 => {
|
||||
// the row byte counts, then the rows they count
|
||||
let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
|
||||
let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
|
||||
let mut total = table;
|
||||
for _ in 0..rows {
|
||||
total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
};
|
||||
if need.is_none_or(|n| n > rest) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((width as u32, height as u32, orientation))
|
||||
}
|
||||
|
||||
/// A zero-filled buffer whose allocation failure is an error, not an abort.
|
||||
fn zeroed<T: Clone + Default>(len: usize, too_large: impl Fn() -> Error) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| too_large())?;
|
||||
v.resize(len, T::default());
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
|
||||
/// A run that would write past the end of the row is an error; trailing bytes are ignored.
|
||||
fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
|
||||
let mut at = 0;
|
||||
while at < row.len() {
|
||||
let Some((&h, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
let h = h as i8;
|
||||
if h >= 0 {
|
||||
let n = h as usize + 1;
|
||||
let lit = src.get(..n).unwrap_or(src);
|
||||
let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
|
||||
dst.copy_from_slice(lit);
|
||||
at += lit.len();
|
||||
src = src.get(n..).unwrap_or(&[]);
|
||||
} else if h != -128 {
|
||||
let n = (1 - h as isize) as usize;
|
||||
let Some((&b, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
|
||||
at += n;
|
||||
}
|
||||
}
|
||||
out.resize(target, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
|
||||
@@ -234,11 +349,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn packbits() {
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
|
||||
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0x05, 1], 4, &mut out);
|
||||
assert_eq!(out, vec![1, 0, 0, 0]);
|
||||
let mut out = [0u8; 6];
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
|
||||
assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
// Short run data is zero-padded.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x01, 1], &mut out).unwrap();
|
||||
assert_eq!(out, [1, 0, 0, 0]);
|
||||
// Truncated literal run: copies what is there.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x03, 7, 8], &mut out).unwrap();
|
||||
assert_eq!(out, [7, 8, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packbits_overrun_is_an_error() {
|
||||
// Repeat run of 128 into a 4-byte row.
|
||||
let mut out = [0u8; 4];
|
||||
assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
|
||||
// Literal run of 6 into a 4-byte row.
|
||||
assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
|
||||
}
|
||||
|
||||
/// PSD header + empty colour-mode/resources/layer sections + compression id.
|
||||
fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec<u8> {
|
||||
let mut b = b"8BPS".to_vec();
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&channels.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(&mode.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
|
||||
b.extend_from_slice(&compression.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
|
||||
// 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
|
||||
let mut b = header(56, 1, 1 << 30, 32, 3, 1);
|
||||
b.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
|
||||
// 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
|
||||
let mut b = header(3, 1 << 30, 1, 32, 3, 1);
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
|
||||
// 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
|
||||
let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
|
||||
b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_repeat_run_past_row_width_is_an_error() {
|
||||
// 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
|
||||
let mut b = header(1, 4, 1, 8, 1, 1);
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0x81, 0x55]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_gray16_and_extra_channels_decode() {
|
||||
// 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
|
||||
// plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
|
||||
let mut b = header(2, 2, 2, 16, 1, 1);
|
||||
for _ in 0..4 {
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
|
||||
let d = decode(&b, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (2, 2));
|
||||
let p = d.image.get(1, 1);
|
||||
let q = d.image.get(0, 0);
|
||||
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ impl Format {
|
||||
pub fn can_decode(self) -> bool {
|
||||
match self {
|
||||
Format::Jxl => cfg!(feature = "jxl"),
|
||||
Format::Avif | Format::Heif | Format::RawTiffLike | Format::RawOther => false,
|
||||
Format::Heif => cfg!(feature = "heif"),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -163,6 +164,9 @@ fn tiff_is_raw(b: &[u8]) -> bool {
|
||||
}
|
||||
let Some(t) = Tiff::new(b) else { return false };
|
||||
let Some(ifd0) = t.first_ifd() else { return false };
|
||||
let sony = t.ifd(ifd0).is_some_and(|(entries, _)| {
|
||||
entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY")))
|
||||
});
|
||||
let mut stack = vec![ifd0];
|
||||
let mut seen = 0;
|
||||
while let Some(pos) = stack.pop() {
|
||||
@@ -171,6 +175,12 @@ fn tiff_is_raw(b: &[u8]) -> bool {
|
||||
break;
|
||||
}
|
||||
let Some((entries, _)) = t.ifd(pos) else { continue };
|
||||
// Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr.
|
||||
// Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs.
|
||||
let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e));
|
||||
if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) {
|
||||
return true;
|
||||
}
|
||||
for e in &entries {
|
||||
match e.tag {
|
||||
0xC612 => return true, // DNGVersion
|
||||
@@ -233,4 +243,38 @@ mod tests {
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
assert_eq!(sniff(&b), Some(Format::RawTiffLike));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() {
|
||||
fn fixture(compression: u32, pattern: bool) -> Vec<u8> {
|
||||
let mut b = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entry = |b: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| {
|
||||
b.extend_from_slice(&tag.to_le_bytes());
|
||||
b.extend_from_slice(&typ.to_le_bytes());
|
||||
b.extend_from_slice(&count.to_le_bytes());
|
||||
b.extend_from_slice(&value.to_le_bytes());
|
||||
};
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
entry(&mut b, 0x010f, 2, 5, 38);
|
||||
entry(&mut b, 0x014a, 4, 1, 44);
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b.extend_from_slice(b"SONY\0\0");
|
||||
b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes());
|
||||
entry(&mut b, 0x0103, 3, 1, compression);
|
||||
entry(&mut b, 0x0106, 3, 1, 6);
|
||||
entry(&mut b, 0x0115, 3, 1, 3);
|
||||
if pattern {
|
||||
entry(&mut b, 0x828e, 1, 4, u32::MAX);
|
||||
}
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
let mut raw = fixture(7, true);
|
||||
assert_eq!(sniff(&raw), Some(Format::RawTiffLike));
|
||||
assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff));
|
||||
assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff));
|
||||
assert_eq!(sniff(&raw[..38]), Some(Format::Tiff));
|
||||
raw[38..42].copy_from_slice(b"TEST");
|
||||
assert_eq!(sniff(&raw), Some(Format::Tiff));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only.
|
||||
//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette
|
||||
//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
@@ -14,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
@@ -25,26 +23,12 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let icc = tag_bytes(&mut d, Tag::IccProfile);
|
||||
let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string());
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16);
|
||||
let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default();
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
|
||||
let (model, n_color, n_samples, bits) = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -52,6 +36,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let n = wu * hu;
|
||||
let mut buf = match res {
|
||||
DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?),
|
||||
DecodingResult::U8(v) => Buf::U8(v),
|
||||
DecodingResult::U16(v) => Buf::U16(v),
|
||||
// Rare integer widths: keep 16 significant bits (plenty for display-referred data).
|
||||
@@ -94,14 +79,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return finish(F, raw, meta, (w, h), opts);
|
||||
}
|
||||
|
||||
// WhiteIsZero → invert.
|
||||
if photometric == Some(0) {
|
||||
match &mut buf {
|
||||
Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x),
|
||||
Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x),
|
||||
Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x),
|
||||
}
|
||||
}
|
||||
// WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and
|
||||
// refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative.
|
||||
|
||||
// Drop extra samples beyond colour + one alpha.
|
||||
let keep = n_color + has_alpha as usize;
|
||||
@@ -113,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
fn limits(opts: &DecodeOptions) -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
|
||||
/// bits per sample).
|
||||
fn layout(ct: ColorType, compression: Option<u32>, planar: bool) -> Result<(Model, usize, usize, u8)> {
|
||||
let l = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if l.3 < 8 && planar && l.2 > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
|
||||
/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
|
||||
/// start past the end of the file (truncated). The compressed data itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
layout(ct, compression, planar)?;
|
||||
// what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
|
||||
if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
|
||||
return Err(Error::Unsupported(F, "compression method"));
|
||||
}
|
||||
let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder<std::io::Cursor<&[u8]>>| {
|
||||
Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
|
||||
};
|
||||
let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
|
||||
.or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
|
||||
.ok_or_else(|| err("no image data"))?;
|
||||
// Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
|
||||
// decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
|
||||
let len = bytes.len() as u64;
|
||||
if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
|
||||
return Err(err("truncated: image data past the end of the file"));
|
||||
}
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
|
||||
Ok((w, h, orientation))
|
||||
}
|
||||
|
||||
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
|
||||
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
@@ -140,6 +180,29 @@ fn unsupported_name(ct: ColorType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most
|
||||
/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to
|
||||
/// 0–255 like any 8-bit sample.
|
||||
fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result<Vec<u8>> {
|
||||
if !matches!(bits, 1 | 2 | 4) {
|
||||
return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits"));
|
||||
}
|
||||
let bits = bits as usize;
|
||||
let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?;
|
||||
let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8);
|
||||
if row_bytes == 0 || packed.len() / row_bytes < height {
|
||||
return Err(err("sample buffer too short"));
|
||||
}
|
||||
let max = (1u8 << bits) - 1;
|
||||
let scale = 255 / max; // 255, 85, 17: exact
|
||||
let mut out = Vec::with_capacity(per_row.saturating_mul(height));
|
||||
for row in packed.chunks_exact(row_bytes).take(height) {
|
||||
let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max));
|
||||
out.extend(values.take(per_row).map(|v| v * scale));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn interleave(buf: Buf, n: usize, s: usize) -> Buf {
|
||||
fn go<T: Copy + Default>(v: Vec<T>, n: usize, s: usize) -> Vec<T> {
|
||||
if v.len() < n * s {
|
||||
|
||||
@@ -12,6 +12,9 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
// RIFF chunk lengths are untrusted. Metadata cannot exceed the input;
|
||||
// otherwise a tiny truncated file can request gigabytes before read_exact fails.
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let alpha = d.has_alpha();
|
||||
@@ -24,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
|
||||
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
|
||||
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
|
||||
/// itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut p = 12usize;
|
||||
loop {
|
||||
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
|
||||
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
||||
let end = p.saturating_add(8).saturating_add(size);
|
||||
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
|
||||
if end > bytes.len() {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
p = end.saturating_add(size & 1);
|
||||
}
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let exif = d.exif_metadata().ok().flatten();
|
||||
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
@@ -30,12 +30,50 @@ fn try_all(bytes: &[u8]) {
|
||||
if let Some(f) = sniff(bytes) {
|
||||
let _ = decode_unguarded(bytes, f, &small_opts());
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
let _ = read_header_unguarded(bytes, f);
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = decode_thumbnail(bytes, 16);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_truncated_metadata_cannot_allocate_declared_chunk_size() {
|
||||
let mut bytes = seeds()[5].clone();
|
||||
let tag = bytes.windows(4).rposition(|w| w == b"XMP ").unwrap();
|
||||
bytes[tag + 4..tag + 8].copy_from_slice(&1_946_157_060u32.to_le_bytes());
|
||||
let decoded = decode(&bytes, small_opts()).unwrap();
|
||||
assert_eq!((decoded.image.width, decoded.image.height), (24, 16));
|
||||
assert!(decoded.xmp.is_none(), "invalid optional metadata is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_rejects_huge_gif_before_allocating_source_pixels() {
|
||||
// A logical screen header without a global palette; previously this reached a
|
||||
// multi-gigabyte output allocation before discovering that no frame follows.
|
||||
let bytes = [
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x8f, 0xa4, 0xb0, 0x3f, 0x57, 0x14, 0xe8, 0x8a, 0xf8, 0xe0, 0xc7, 0x41, 0x9d, 0x0c, 0x10, 0xde, 0x54,
|
||||
0x24, 0xcb, 0xed, 0xff, 0x4a, 0xe7, 0xa0, 0x82, 0x10, 0xa0, 0xbc, 0xac, 0xe5, 0x33, 0x3f, 0xc9, 0x8d, 0x69, 0xf4, 0x24, 0xce, 0x99, 0x4f,
|
||||
0x0d, 0x47, 0xa0, 0xb0, 0xf4, 0xa8, 0x59, 0xad, 0x29, 0xbd, 0x09, 0x48, 0x63, 0xea, 0x59, 0xb7, 0xb7, 0xf2, 0xa1, 0x90, 0xeb, 0x92, 0xc5,
|
||||
0x9c, 0xde, 0xcc, 0x96, 0x67, 0xc4, 0xe5, 0x8c, 0x03, 0x50, 0xb2, 0x30, 0xf8, 0xf4, 0x69, 0x1d, 0xeb, 0xb9, 0xbd, 0x4e, 0xfb, 0xbd, 0xb6,
|
||||
0x02,
|
||||
];
|
||||
let result = decode_thumbnail(&bytes, 16);
|
||||
assert!(matches!(result, Err(Error::TooLarge(42_127, 16_304))), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_respects_configured_source_pixel_limit() {
|
||||
let image = Rgba8::from_fn(3, 2, |_, _| [40, 80, 120, 255]);
|
||||
let bytes = encode_png(&EncodeImage::rgba8(&image), &EncodeMeta::default()).unwrap();
|
||||
let mut opts = ThumbnailOptions::new(1);
|
||||
opts.max_pixels = 5;
|
||||
assert!(matches!(decode_thumbnail_with(&bytes, &opts), Err(Error::TooLarge(3, 2))));
|
||||
opts.max_pixels = 6;
|
||||
assert_eq!(decode_thumbnail_with(&bytes, &opts).unwrap().image.width, 1);
|
||||
}
|
||||
|
||||
fn seeds() -> &'static Vec<Vec<u8>> {
|
||||
static S: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
|
||||
S.get_or_init(|| {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
|
||||
//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
|
||||
|
||||
use lightcraft_codecs::exif::minimal_exif;
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn pixels(w: usize, h: usize) -> Rgba8 {
|
||||
Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
|
||||
}
|
||||
|
||||
fn meta(exif: &Option<Vec<u8>>) -> EncodeMeta<'_> {
|
||||
EncodeMeta { exif: exif.as_deref(), xmp: Some("<x/>"), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
|
||||
fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut cur = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut cur).unwrap();
|
||||
let mut im = enc.new_image::<colortype::RGB8>(img.width as u32, img.height as u32).unwrap();
|
||||
im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
|
||||
im.write_data(&rgb).unwrap();
|
||||
cur.into_inner()
|
||||
}
|
||||
|
||||
/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
|
||||
fn psd(w: usize, h: usize, orientation: Option<u16>, rle: bool) -> Vec<u8> {
|
||||
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(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
b.extend_from_slice(&(w as u32).to_be_bytes());
|
||||
b.extend_from_slice(&8u16.to_be_bytes());
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||||
let mut res = Vec::new();
|
||||
if let Some(o) = orientation {
|
||||
let exif = minimal_exif(o);
|
||||
res.extend_from_slice(b"8BIM");
|
||||
res.extend_from_slice(&1058u16.to_be_bytes());
|
||||
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
|
||||
res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
|
||||
res.extend_from_slice(&exif);
|
||||
if exif.len() % 2 == 1 {
|
||||
res.push(0);
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(&res);
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
|
||||
b.extend_from_slice(&(rle as u16).to_be_bytes());
|
||||
let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
|
||||
if rle {
|
||||
// every row as literal runs of at most 128 bytes (PackBits' longest)
|
||||
let row_len = w + w.div_ceil(128);
|
||||
for _ in 0..3 * h {
|
||||
b.extend_from_slice(&(row_len as u16).to_be_bytes());
|
||||
}
|
||||
for c in 0..3 {
|
||||
let p: Vec<u8> = plane(c).collect();
|
||||
for row in p.chunks(w) {
|
||||
for run in row.chunks(128) {
|
||||
b.push(run.len() as u8 - 1);
|
||||
b.extend_from_slice(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for c in 0..3 {
|
||||
b.extend(plane(c));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
|
||||
fn fixtures(w: usize, h: usize) -> Vec<(String, Vec<u8>)> {
|
||||
let img = pixels(w, h);
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
|
||||
let u16s: Vec<u16> = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
|
||||
let mut out = Vec::new();
|
||||
for o in [None, Some(1), Some(6), Some(8)] {
|
||||
let exif = o.map(minimal_exif);
|
||||
let tag = format!("o{}", o.unwrap_or(0));
|
||||
out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
|
||||
out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
|
||||
out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("psd {tag}"), psd(w, h, o, false)));
|
||||
out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
|
||||
out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
|
||||
}
|
||||
out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
|
||||
out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
|
||||
let mut bmp = Vec::new();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
|
||||
out.push(("bmp".into(), bmp));
|
||||
out
|
||||
}
|
||||
|
||||
/// What the import probe learnt before it read headers: a small decode.
|
||||
fn decoded(bytes: &[u8]) -> Result<Header> {
|
||||
let d = decode(bytes, DecodeOptions::fit(64, 64))?;
|
||||
Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_matches_decode() {
|
||||
for (w, h) in [(160, 96), (96, 160), (7, 5)] {
|
||||
for (name, bytes) in fixtures(w, h) {
|
||||
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
|
||||
assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
|
||||
assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
|
||||
}
|
||||
}
|
||||
let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
|
||||
assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
|
||||
}
|
||||
|
||||
/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
|
||||
/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
|
||||
/// is checked for truncation, which the header readers detect.
|
||||
#[test]
|
||||
fn truncated_files_agree_with_decode() {
|
||||
// 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
let n = bytes.len();
|
||||
let mut cuts: Vec<usize> = (0..100).map(|i| n * i / 100).collect();
|
||||
cuts.extend(n.saturating_sub(64)..n);
|
||||
for cut in cuts {
|
||||
let part = &bytes[..cut];
|
||||
if let Ok(d) = decoded(part) {
|
||||
assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
|
||||
}
|
||||
}
|
||||
for cut in [n / 4, n / 2, n * 9 / 10] {
|
||||
assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_details() {
|
||||
let f = fixtures(160, 96);
|
||||
let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
|
||||
let jpeg = get("jpeg 420 o6");
|
||||
assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
|
||||
let mut trailing = jpeg.clone();
|
||||
trailing.extend_from_slice(&[0; 100]);
|
||||
assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
|
||||
let png = get("png rgba o6");
|
||||
assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
|
||||
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
|
||||
}
|
||||
|
||||
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
|
||||
#[test]
|
||||
fn png_exif_after_idat() {
|
||||
let img = pixels(9, 4);
|
||||
let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
|
||||
let exif = minimal_exif(6);
|
||||
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
|
||||
chunk.extend_from_slice(b"eXIf");
|
||||
chunk.extend_from_slice(&exif);
|
||||
chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
|
||||
let iend = plain.len() - 12;
|
||||
let mut b = plain[..iend].to_vec();
|
||||
b.extend_from_slice(&chunk);
|
||||
b.extend_from_slice(&plain[iend..]);
|
||||
assert_eq!(read_header(&b).unwrap().orientation, 6);
|
||||
}
|
||||
|
||||
fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.push(comps);
|
||||
for c in 0..comps {
|
||||
b.extend_from_slice(&[c + 1, 0x11, 0]);
|
||||
}
|
||||
b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
|
||||
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
|
||||
b.extend_from_slice(&[0xFF, 0xD9]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_chunk(b: &mut Vec<u8>, kind: &[u8; 4], len: u32, data: &[u8]) {
|
||||
b.extend_from_slice(&len.to_be_bytes());
|
||||
b.extend_from_slice(kind);
|
||||
b.extend_from_slice(data);
|
||||
b.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_are_errors() {
|
||||
// a well-formed synthetic JPEG header is read
|
||||
assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
|
||||
let jpeg_bad = [
|
||||
jpeg_segments(0xC0, 0, 21, 3), // zero width
|
||||
jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
|
||||
jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
|
||||
jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
|
||||
jpeg_segments(0xC5, 33, 21, 3), // hierarchical
|
||||
jpeg_segments(0xC0, 33, 21, 2), // two components
|
||||
jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
|
||||
vec![0xFF, 0xD8, 0xFF, 0xD9],
|
||||
];
|
||||
for (i, b) in jpeg_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "JPEG case {i}");
|
||||
}
|
||||
let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
|
||||
no_sos.truncate(2 + 2 + 17);
|
||||
no_sos.extend_from_slice(&[0xFF, 0xD9]);
|
||||
assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
|
||||
|
||||
let mut ok = png_ihdr(5, 3, 8, 2);
|
||||
png_chunk(&mut ok, b"IDAT", 1, &[0]);
|
||||
png_chunk(&mut ok, b"IEND", 0, &[]);
|
||||
assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
|
||||
let png_case = |ihdr: Vec<u8>, chunks: &[(&[u8; 4], u32, &[u8])]| {
|
||||
let mut b = ihdr;
|
||||
for (k, l, d) in chunks {
|
||||
png_chunk(&mut b, k, *l, d);
|
||||
}
|
||||
b
|
||||
};
|
||||
let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
|
||||
let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
|
||||
let png_bad = [
|
||||
png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
|
||||
png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
|
||||
png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
|
||||
png_ihdr(5, 3, 8, 2)[..20].to_vec(),
|
||||
b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in png_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PNG case {i}");
|
||||
}
|
||||
|
||||
let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
|
||||
let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
|
||||
let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
|
||||
// cut short after the IFD
|
||||
assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
|
||||
tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
|
||||
for (i, b) in tiff_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "TIFF case {i}");
|
||||
}
|
||||
// a strip offset pointing past the end of the file
|
||||
let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
|
||||
let n = t(&tiff, ifd) as usize;
|
||||
let mut far = tiff.clone();
|
||||
for e in 0..n {
|
||||
let p = ifd + 2 + e * 12;
|
||||
if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
|
||||
far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
assert_ne!(far, tiff, "the strip offset was found");
|
||||
assert!(read_header(&far).is_err(), "strip past the end");
|
||||
|
||||
let webp_bad = [
|
||||
b"RIFF\0\0\0\0WEBP".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in webp_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "WebP case {i}");
|
||||
}
|
||||
|
||||
let good = psd(4, 3, Some(6), true);
|
||||
assert_eq!(read_header(&good).unwrap().orientation, 6);
|
||||
let set = |pos: usize, v: &[u8]| {
|
||||
let mut b = good.clone();
|
||||
b[pos..pos + v.len()].copy_from_slice(v);
|
||||
b
|
||||
};
|
||||
let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
|
||||
let compression = 34 + res_len + 4;
|
||||
let mut psd_bad = vec![
|
||||
set(12, &0u16.to_be_bytes()), // no channels
|
||||
set(12, &57u16.to_be_bytes()), // too many
|
||||
set(12, &2u16.to_be_bytes()), // too few for RGB
|
||||
set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
|
||||
set(18, &0u32.to_be_bytes()), // zero width
|
||||
set(22, &1u16.to_be_bytes()), // 1-bit
|
||||
set(22, &7u16.to_be_bytes()), // bad depth
|
||||
set(24, &9u16.to_be_bytes()), // Lab
|
||||
set(24, &77u16.to_be_bytes()), // unknown mode
|
||||
set(24, &2u16.to_be_bytes()), // indexed without a palette
|
||||
set(compression, &2u16.to_be_bytes()), // ZIP
|
||||
set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
|
||||
set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
|
||||
];
|
||||
// a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
|
||||
let mut tall = good.clone();
|
||||
tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
|
||||
tall[18..22].copy_from_slice(&1u32.to_be_bytes());
|
||||
psd_bad.push(tall);
|
||||
psd_bad.push(good[..good.len() - 1].to_vec());
|
||||
for (i, b) in psd_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PSD case {i}");
|
||||
}
|
||||
|
||||
for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
|
||||
assert!(read_header(b).is_err(), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
|
||||
/// header agrees with it (deterministic seed).
|
||||
#[test]
|
||||
fn mutated_files_agree_with_decode() {
|
||||
let mut s: u64 = 0x1234_5678_9abc_def1;
|
||||
let mut rnd = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
s
|
||||
};
|
||||
let (mut decodable, mut disagreements) = (0, Vec::new());
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
for _ in 0..300 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..1 + rnd() % 3 {
|
||||
let i = rnd() as usize % b.len();
|
||||
b[i] = rnd() as u8;
|
||||
}
|
||||
if let Ok(d) = decoded(&b) {
|
||||
decodable += 1;
|
||||
let h = read_header(&b);
|
||||
if h.as_ref().ok() != Some(&d) {
|
||||
disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(decodable > 1000, "{decodable}");
|
||||
assert!(disagreements.is_empty(), "{disagreements:#?}");
|
||||
}
|
||||
@@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() {
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
|
||||
}
|
||||
|
||||
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
|
||||
/// already packed into `strip`, one padded row after another.
|
||||
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> {
|
||||
const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4;
|
||||
let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat();
|
||||
let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend(8u32.to_le_bytes());
|
||||
b.extend(9u16.to_le_bytes());
|
||||
b.extend(short(256, width));
|
||||
b.extend(short(257, height));
|
||||
b.extend(short(258, bits));
|
||||
b.extend(short(259, 1));
|
||||
b.extend(short(262, photometric));
|
||||
b.extend(long(273, STRIP_AT));
|
||||
b.extend(short(277, 1));
|
||||
b.extend(short(278, height));
|
||||
b.extend(long(279, strip.len() as u32));
|
||||
b.extend(0u32.to_le_bytes());
|
||||
assert_eq!(b.len(), STRIP_AT as usize);
|
||||
b.extend_from_slice(strip);
|
||||
b
|
||||
}
|
||||
|
||||
/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these
|
||||
/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative.
|
||||
#[test]
|
||||
fn tiff_white_is_zero_is_inverted_once() {
|
||||
// Two pixels: the lowest sample, then the highest, at 8 and 16 bits.
|
||||
for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] {
|
||||
for (photometric, first) in [(0, 1.0), (1, 0.0)] {
|
||||
let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap();
|
||||
let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]);
|
||||
assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or
|
||||
/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short".
|
||||
#[test]
|
||||
fn tiff_samples_narrower_than_a_byte() {
|
||||
let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1];
|
||||
|
||||
// 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored).
|
||||
// Row 0 alternates set/clear bits; row 1 is clear but for its last pixel.
|
||||
let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111];
|
||||
// What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0).
|
||||
for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] {
|
||||
let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1));
|
||||
assert!(d.grayscale);
|
||||
let clear_bit = 1.0 - set_bit;
|
||||
for x in 0..10 {
|
||||
let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit });
|
||||
assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}");
|
||||
assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding).
|
||||
let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 85, 170, 255].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}");
|
||||
}
|
||||
let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 255, 136].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}");
|
||||
}
|
||||
|
||||
// A strip shorter than its rows is an error, never a panic.
|
||||
assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossless_exact() {
|
||||
let img = gradient(31, 19);
|
||||
@@ -461,7 +535,7 @@ fn thumbnail_accepts_smaller_preview_when_asked() {
|
||||
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::Scaled);
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { max_edge: 256, min_embedded_edge: 160 }).unwrap();
|
||||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { min_embedded_edge: 160, ..ThumbnailOptions::new(256) }).unwrap();
|
||||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||||
assert_eq!((t.image.width, t.image.height), (160, 120));
|
||||
}
|
||||
|
||||
+17
-6
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
|
||||
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
|
||||
//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
|
||||
//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green.
|
||||
//! A positive correction neutralises a green illuminant above the locus, so its source white uses
|
||||
//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast.
|
||||
|
||||
use crate::{Mat3, RgbSpace, Xy, bradford};
|
||||
|
||||
@@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) {
|
||||
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
|
||||
}
|
||||
|
||||
/// Chromaticity of the white point for temperature `t` (K) and `tint`.
|
||||
/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`.
|
||||
/// Positive tint means a green source white, whose correction adds magenta to the image.
|
||||
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
|
||||
let (u, v) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = -tint / TINT_SCALE;
|
||||
let duv = tint / TINT_SCALE;
|
||||
uv_to_xy(u + nu * duv, v + nv * duv)
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
|
||||
let (lu, lv) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = (u - lu) * nu + (v - lv) * nv;
|
||||
(t, -duv * TINT_SCALE)
|
||||
(t, duv * TINT_SCALE)
|
||||
}
|
||||
|
||||
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
|
||||
@@ -141,10 +144,18 @@ mod tests {
|
||||
let warm = temp_tint_to_xy(3000.0, 0.0);
|
||||
let cool = temp_tint_to_xy(9000.0, 0.0);
|
||||
assert!(warm.x > cool.x);
|
||||
// positive tint is magenta: lower v
|
||||
// A positive magenta correction specifies a greener source illuminant: higher v.
|
||||
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
|
||||
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
|
||||
assert!(v1 < v0);
|
||||
assert!(v1 > v0);
|
||||
}
|
||||
|
||||
/// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as
|
||||
/// Lightroom's Daylight preset (5500 K, +10) has it (#188).
|
||||
#[test]
|
||||
fn daylight_has_a_positive_tint() {
|
||||
let (_, tint) = xy_to_temp_tint(crate::D65);
|
||||
assert!((5.0..15.0).contains(&tint), "{tint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "lightcraft-denoise-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
rawnind-model = []
|
||||
|
||||
[dependencies]
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,7 @@
|
||||
# lightcraft-denoise-core
|
||||
|
||||
L1 model metadata, bounded archive/cache formats and Bayer packing/tiling helpers.
|
||||
No weights or inference backend. See [the user workflow](../../docs/denoise.md).
|
||||
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
|
||||
crate directly when inference is disabled. The default download catalog is empty.
|
||||
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Taking one file out of a `.zip` (a `.dtmodel` is one), the little of the format a model download needs: stored and
|
||||
//! deflated entries, found by name, checked against their CRC-32 and a size limit, written next to where they are
|
||||
//! going and moved into place. It is a stranger's file, so every length and offset in it is checked, a name is only
|
||||
//! ever compared (never used as a path), and zip64, encrypted and multi-disk archives are refused.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// How far from the end the end-of-central-directory record can start (a 22-byte record and a 64 KiB comment).
|
||||
const EOCD_SEARCH: u64 = 22 + 65535;
|
||||
/// Largest central directory we read.
|
||||
const MAX_DIRECTORY: u64 = 1 << 20;
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ArchiveError {
|
||||
#[error("the archive could not be read: {0}")]
|
||||
Io(String),
|
||||
#[error("not a usable archive: {0}")]
|
||||
Format(String),
|
||||
#[error("the archive has no file called `{0}`")]
|
||||
Missing(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArchiveError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ArchiveError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn format<T>(why: &str) -> Result<T, ArchiveError> {
|
||||
Err(ArchiveError::Format(why.into()))
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE) of `bytes`.
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut table = [0u32; 256];
|
||||
for (i, t) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*t = c;
|
||||
}
|
||||
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
method: u16,
|
||||
crc: u32,
|
||||
compressed: u64,
|
||||
size: u64,
|
||||
local_header: u64,
|
||||
}
|
||||
|
||||
/// Find the entry called `name` (exactly, or as the last part of a longer name: `folder/name`) in the central directory.
|
||||
fn find(f: &mut (impl Read + Seek), name: &str) -> Result<Entry, ArchiveError> {
|
||||
let len = f.seek(SeekFrom::End(0))?;
|
||||
let tail_len = len.min(EOCD_SEARCH);
|
||||
f.seek(SeekFrom::Start(len - tail_len))?;
|
||||
let mut tail = vec![0u8; tail_len as usize];
|
||||
f.read_exact(&mut tail)?;
|
||||
let eocd = (0..tail.len().saturating_sub(21)).rev().find(|&i| u32_at(&tail, i) == Some(0x0605_4b50));
|
||||
let Some(e) = eocd else { return format("this is not a zip file") };
|
||||
let (entries, size, offset) = (u16_at(&tail, e + 10), u32_at(&tail, e + 12), u32_at(&tail, e + 16));
|
||||
let (Some(entries), Some(size), Some(offset)) = (entries, size, offset) else { return format("the end of the archive is cut short") };
|
||||
if entries == 0xffff || size == u32::MAX || offset == u32::MAX {
|
||||
return format("zip64 archives are not supported");
|
||||
}
|
||||
if u64::from(size) > MAX_DIRECTORY || u64::from(offset) + u64::from(size) > len {
|
||||
return format("the list of files is not where it says");
|
||||
}
|
||||
f.seek(SeekFrom::Start(u64::from(offset)))?;
|
||||
let mut dir = vec![0u8; size as usize];
|
||||
f.read_exact(&mut dir)?;
|
||||
let mut at = 0usize;
|
||||
for _ in 0..entries {
|
||||
if u32_at(&dir, at) != Some(0x0201_4b50) {
|
||||
return format("the list of files is damaged");
|
||||
}
|
||||
let get = |o: usize| at.checked_add(o);
|
||||
let (flags, method, crc) = (u16_at(&dir, at + 8), u16_at(&dir, at + 10), u32_at(&dir, at + 16));
|
||||
let (compressed, uncompressed) = (u32_at(&dir, at + 20), u32_at(&dir, at + 24));
|
||||
let (n, x, c) = (u16_at(&dir, at + 28), u16_at(&dir, at + 30), u16_at(&dir, at + 32));
|
||||
let local = u32_at(&dir, at + 42);
|
||||
let (Some(flags), Some(method), Some(crc), Some(compressed), Some(uncompressed), Some(n), Some(x), Some(c), Some(local), Some(start)) =
|
||||
(flags, method, crc, compressed, uncompressed, n, x, c, local, get(46))
|
||||
else {
|
||||
return format("the list of files is cut short");
|
||||
};
|
||||
let Some(entry_name) = dir.get(start..start + usize::from(n)) else { return format("a file name is cut short") };
|
||||
let matches = entry_name == name.as_bytes() || entry_name.strip_suffix(name.as_bytes()).is_some_and(|p| p.ends_with(b"/"));
|
||||
if matches {
|
||||
if flags & 1 != 0 {
|
||||
return format("encrypted files are not supported");
|
||||
}
|
||||
if compressed == u32::MAX || uncompressed == u32::MAX {
|
||||
return format("zip64 files are not supported");
|
||||
}
|
||||
return Ok(Entry { method, crc, compressed: u64::from(compressed), size: u64::from(uncompressed), local_header: u64::from(local) });
|
||||
}
|
||||
at = start + usize::from(n) + usize::from(x) + usize::from(c);
|
||||
}
|
||||
Err(ArchiveError::Missing(name.into()))
|
||||
}
|
||||
|
||||
/// Take the file called `name` out of the archive at `archive` and write it to `dest` (through a temporary file next
|
||||
/// to it); `max_bytes` bounds its size. Returns the number of bytes written.
|
||||
pub fn extract(archive: &Path, name: &str, dest: &Path, max_bytes: u64) -> Result<u64, ArchiveError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(archive)?);
|
||||
let e = find(&mut f, name)?;
|
||||
if e.size > max_bytes || e.compressed > max_bytes {
|
||||
return format("the file in the archive is larger than allowed");
|
||||
}
|
||||
f.seek(SeekFrom::Start(e.local_header))?;
|
||||
let mut head = [0u8; 30];
|
||||
f.read_exact(&mut head)?;
|
||||
if u32_at(&head, 0) != Some(0x0403_4b50) {
|
||||
return format("the file's header is not where it says");
|
||||
}
|
||||
let skip = u64::from(u16_at(&head, 26).unwrap_or(0)) + u64::from(u16_at(&head, 28).unwrap_or(0));
|
||||
f.seek_relative(i64::try_from(skip).map_err(|_| ArchiveError::Format("a header is too long".into()))?)?;
|
||||
let len = usize::try_from(e.compressed)
|
||||
.ok()
|
||||
.filter(|&n| n <= 256 * 1024 * 1024)
|
||||
.ok_or_else(|| ArchiveError::Format("the compressed model is too large".into()))?;
|
||||
if e.size > 256 * 1024 * 1024 {
|
||||
return format("the extracted model is too large");
|
||||
}
|
||||
let mut packed = Vec::new();
|
||||
packed.try_reserve_exact(len).map_err(|_| ArchiveError::Format("not enough memory for the model archive".into()))?;
|
||||
packed.resize(len, 0u8);
|
||||
f.read_exact(&mut packed)?;
|
||||
let data = match e.method {
|
||||
0 => packed,
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(&packed, max_bytes.min(256 * 1024 * 1024) as usize)
|
||||
.map_err(|err| ArchiveError::Format(format!("{err:?}")))?,
|
||||
m => return format(&format!("compression method {m} is not supported")),
|
||||
};
|
||||
if data.len() as u64 != e.size || crc32(&data) != e.crc {
|
||||
return format("the file in the archive is damaged");
|
||||
}
|
||||
let tmp = dest.with_extension("part");
|
||||
let written = (|| -> std::io::Result<()> {
|
||||
let mut out = std::fs::File::create(&tmp)?;
|
||||
out.write_all(&data)?;
|
||||
out.flush()?;
|
||||
drop(out);
|
||||
std::fs::rename(&tmp, dest)
|
||||
})();
|
||||
if let Err(err) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(data.len() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A zip with the given (name, bytes, deflate) entries, written by hand.
|
||||
pub(crate) fn make_zip(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut dir = Vec::new();
|
||||
for (name, data, deflate) in files {
|
||||
let packed = if *deflate { miniz_oxide::deflate::compress_to_vec(data, 6) } else { data.to_vec() };
|
||||
let offset = out.len() as u32;
|
||||
let method: u16 = if *deflate { 8 } else { 0 };
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[20, 0, 0, 0]);
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&crc32(data).to_le_bytes());
|
||||
out.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&packed);
|
||||
dir.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
dir.extend_from_slice(&[20, 0, 20, 0, 0, 0]);
|
||||
dir.extend_from_slice(&method.to_le_bytes());
|
||||
dir.extend_from_slice(&[0, 0, 0, 0]);
|
||||
dir.extend_from_slice(&crc32(data).to_le_bytes());
|
||||
dir.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
dir.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
dir.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
dir.extend_from_slice(&[0; 12]);
|
||||
dir.extend_from_slice(&offset.to_le_bytes());
|
||||
dir.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd_offset = out.len() as u32;
|
||||
out.extend_from_slice(&dir);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(dir.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd_offset.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-zip-{name}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_and_a_deflated_file_come_out_whole() {
|
||||
let big: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let zip = make_zip(&[("m/config.json", b"{}", false), ("m/model.onnx", &big, true), ("m/other.onnx", b"zz", true)]);
|
||||
let (a, out) = (temp("a.zip"), temp("a.onnx"));
|
||||
std::fs::write(&a, &zip).unwrap();
|
||||
assert_eq!(extract(&a, "model.onnx", &out, 1 << 20).unwrap(), big.len() as u64);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), big);
|
||||
assert_eq!(extract(&a, "m/config.json", &out, 1 << 20).unwrap(), 2);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"{}");
|
||||
// a name must match whole path parts: `del.onnx` is not `model.onnx`
|
||||
assert!(matches!(extract(&a, "del.onnx", &out, 1 << 20), Err(ArchiveError::Missing(_))));
|
||||
assert!(matches!(extract(&a, "nothing", &out, 1 << 20), Err(ArchiveError::Missing(_))));
|
||||
// the size limit applies
|
||||
assert!(matches!(extract(&a, "model.onnx", &out, 1000), Err(ArchiveError::Format(_))));
|
||||
for p in [a, out] {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: `LC_DENOISE_DTMODEL=<rawdenoise-nind.dtmodel> cargo test -p lightcraft-denoise -- --ignored archive`
|
||||
#[test]
|
||||
#[ignore = "needs the real download: set LC_DENOISE_DTMODEL"]
|
||||
fn the_real_download_gives_the_model_it_should() {
|
||||
let Some(path) = std::env::var_os("LC_DENOISE_DTMODEL") else { return };
|
||||
let known = crate::known::find("rawnind-bayer").unwrap();
|
||||
let (d, entry) = (known.download.unwrap(), known.entry.unwrap());
|
||||
assert_eq!(crate::hash::sha256_file(Path::new(&path)).unwrap(), d.sha256);
|
||||
let out = temp("real.onnx");
|
||||
let n = extract(Path::new(&path), entry, &out, crate::manifest::MAX_MODEL_BYTES).unwrap();
|
||||
assert_eq!(Some(n), known.manifest.size_bytes);
|
||||
assert_eq!(Some(crate::hash::sha256_file(&out).unwrap()), known.manifest.sha256);
|
||||
let _ = std::fs::remove_file(out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_archives_are_errors_not_panics() {
|
||||
let zip = make_zip(&[("m/model.onnx", &vec![9u8; 5000], true)]);
|
||||
let (a, out) = (temp("b.zip"), temp("b.onnx"));
|
||||
// cut anywhere
|
||||
for n in (0..zip.len()).step_by(3) {
|
||||
std::fs::write(&a, &zip[..n]).unwrap();
|
||||
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err(), "cut at {n}");
|
||||
}
|
||||
// one byte changed anywhere: an error, or (when it falls in an unused field) the right file, never a panic
|
||||
for at in 0..zip.len() {
|
||||
let mut bad = zip.clone();
|
||||
bad[at] ^= 0x5a;
|
||||
std::fs::write(&a, &bad).unwrap();
|
||||
if extract(&a, "model.onnx", &out, 1 << 20).is_ok() {
|
||||
assert_eq!(std::fs::read(&out).unwrap(), vec![9u8; 5000], "a changed byte at {at} gave another file");
|
||||
}
|
||||
}
|
||||
// not a zip at all, and an empty file
|
||||
for junk in [Vec::new(), b"PK".to_vec(), vec![0u8; 100_000]] {
|
||||
std::fs::write(&a, junk).unwrap();
|
||||
assert!(extract(&a, "model.onnx", &out, 1 << 20).is_err());
|
||||
}
|
||||
assert!(extract(&temp("missing.zip"), "model.onnx", &out, 1 << 20).is_err());
|
||||
for p in [a, out] {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Bayer geometry: which way a mosaic is laid out, and how a patch of it is packed into the four planes
|
||||
//! (red, green on the red row, green on the blue row, blue) a denoise model takes.
|
||||
//!
|
||||
//! Every Bayer layout is the same 2 × 2 cell shifted by one pixel at most, so a layout is just where its
|
||||
//! first red sample is. The cell grid starts one pixel before the picture when that red sample is in
|
||||
//! column or row 1, and the picture is read with its edge mirrored (without repeating the edge sample, so
|
||||
//! the colour of a mirrored sample is the colour that belongs at that place).
|
||||
|
||||
/// Where the first red sample of a Bayer mosaic sits, `(dx, dy)` in `{0, 1}²`: RGGB is `(0, 0)`, GRBG `(1, 0)`,
|
||||
/// GBRG `(0, 1)`, BGGR `(1, 1)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Layout {
|
||||
pub dx: usize,
|
||||
pub dy: usize,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when
|
||||
/// it is not a Bayer cell (one red, one blue, the greens on the other diagonal).
|
||||
pub fn from_cell(cell: [u8; 4]) -> Option<Layout> {
|
||||
let at = |c: u8| cell.iter().position(|&v| v == c);
|
||||
let red = at(0)?;
|
||||
let blue = at(2)?;
|
||||
let greens = cell.iter().filter(|&&v| v == 1).count();
|
||||
// red and blue sit on one diagonal: indices 0+3 or 1+2
|
||||
if greens != 2 || red + blue != 3 {
|
||||
return None;
|
||||
}
|
||||
Some(Layout { dx: red % 2, dy: red / 2 })
|
||||
}
|
||||
|
||||
/// The cell grid's origin: `-1` along an axis whose first red sample is at 1, so that cell 0 starts on a red column or row.
|
||||
fn origin(self) -> (isize, isize) {
|
||||
(isize::try_from(self.dx).unwrap_or(isize::MAX).saturating_neg(), isize::try_from(self.dy).unwrap_or(isize::MAX).saturating_neg())
|
||||
}
|
||||
|
||||
/// Cells across and down covering a `width × height` mosaic.
|
||||
pub fn cells(self, width: usize, height: usize) -> (usize, usize) {
|
||||
let cells = |n: usize, phase: usize| n / 2 + (n % 2 + phase.min(1)).div_ceil(2);
|
||||
(cells(width, self.dx), cells(height, self.dy))
|
||||
}
|
||||
|
||||
/// The mosaic position of cell `(cx, cy)`'s top-left sample (may lie outside the picture).
|
||||
pub fn position(self, cx: isize, cy: isize) -> (isize, isize) {
|
||||
let (ox, oy) = self.origin();
|
||||
(ox.saturating_add(cx.saturating_mul(2)), oy.saturating_add(cy.saturating_mul(2)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `i` mirrored into `0..n` (ping-pong, edge not repeated), so `-1` is `1` and `n` is `n - 2`. `n == 0` gives 0.
|
||||
pub fn reflect(i: isize, n: usize) -> usize {
|
||||
if n <= 1 {
|
||||
return 0;
|
||||
}
|
||||
if let Some(period) = isize::try_from(n).ok().and_then(|n| (n - 1).checked_mul(2)) {
|
||||
let m = i.rem_euclid(period);
|
||||
return (if m < n as isize { m } else { period - m }) as usize;
|
||||
}
|
||||
let period = 2 * (n as i128 - 1);
|
||||
let m = (i as i128).rem_euclid(period);
|
||||
// The reflected value is in 0..n, so this conversion always fits usize.
|
||||
(if m < n as i128 { m } else { period - m }) as usize
|
||||
}
|
||||
|
||||
/// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]`
|
||||
/// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside
|
||||
/// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit.
|
||||
pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool {
|
||||
let Some(plane) = tile.checked_mul(tile) else { return false };
|
||||
let Some(values) = plane.checked_mul(4) else { return false };
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| tile == 0
|
||||
|| layout.dx > 1
|
||||
|| layout.dy > 1
|
||||
|| width.checked_mul(height) != Some(mosaic.len())
|
||||
|| out.len() != values
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(last) = isize::try_from(tile - 1) else { return false };
|
||||
let (ox, oy) = layout.origin();
|
||||
for (start, origin) in [(cx0, ox), (cy0, oy)] {
|
||||
if start.checked_mul(2).and_then(|v| v.checked_add(origin)).is_none()
|
||||
|| start.checked_add(last).and_then(|v| v.checked_mul(2)).and_then(|v| v.checked_add(origin)).and_then(|v| v.checked_add(1)).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// the four samples of a cell, as (dx, dy) from its top-left sample
|
||||
const SUB: [(isize, isize); 4] = [(0, 0), (1, 0), (0, 1), (1, 1)];
|
||||
for (p, &(sx, sy)) in SUB.iter().enumerate() {
|
||||
let Some(dst) = out.get_mut(p * plane..(p + 1) * plane) else { return false };
|
||||
for (j, row) in dst.chunks_exact_mut(tile).enumerate() {
|
||||
let (_, y) = layout.position(cx0, cy0 + j as isize);
|
||||
let ry = reflect(y + sy, height);
|
||||
let Some(src) = mosaic.get(ry * width..(ry + 1) * width) else { return false };
|
||||
for (i, v) in row.iter_mut().enumerate() {
|
||||
let (x, _) = layout.position(cx0 + i as isize, cy0);
|
||||
*v = src.get(reflect(x + sx, width)).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_positions_never_panic_or_write_output() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let mut out = [7.0; 4];
|
||||
for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] {
|
||||
assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out));
|
||||
assert_eq!(out, [7.0; 4]);
|
||||
}
|
||||
for n in [1, 2, isize::MAX as usize, usize::MAX] {
|
||||
for i in [isize::MIN, -1, 0, isize::MAX] {
|
||||
assert!(reflect(i, n) < n);
|
||||
}
|
||||
}
|
||||
assert_eq!(layout.cells(usize::MAX, usize::MAX), (usize::MAX / 2 + 1, usize::MAX / 2 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_from_cells() {
|
||||
assert_eq!(Layout::from_cell([0, 1, 1, 2]), Some(Layout { dx: 0, dy: 0 }));
|
||||
assert_eq!(Layout::from_cell([1, 0, 2, 1]), Some(Layout { dx: 1, dy: 0 }));
|
||||
assert_eq!(Layout::from_cell([1, 2, 0, 1]), Some(Layout { dx: 0, dy: 1 }));
|
||||
assert_eq!(Layout::from_cell([2, 1, 1, 0]), Some(Layout { dx: 1, dy: 1 }));
|
||||
// not Bayer: two reds, a missing blue, greens on the wrong diagonal
|
||||
assert_eq!(Layout::from_cell([0, 0, 1, 2]), None);
|
||||
assert_eq!(Layout::from_cell([0, 1, 2, 1]), None);
|
||||
assert_eq!(Layout::from_cell([1, 1, 1, 1]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_keeps_the_colour_of_a_site() {
|
||||
assert_eq!(reflect(-1, 10), 1);
|
||||
assert_eq!(reflect(-2, 10), 2);
|
||||
assert_eq!(reflect(10, 10), 8);
|
||||
assert_eq!(reflect(11, 10), 7);
|
||||
assert_eq!(reflect(25, 10), 7);
|
||||
assert_eq!(reflect(5, 0), 0);
|
||||
assert_eq!(reflect(5, 1), 0);
|
||||
for i in -40..60 {
|
||||
assert_eq!(reflect(i, 11) % 2, (i.rem_euclid(2)) as usize, "parity at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_puts_each_colour_in_its_plane_for_every_layout() {
|
||||
// a mosaic whose sample value says which colour it is: R = 10, G = 20, B = 30
|
||||
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
|
||||
let layout = Layout::from_cell(cell).unwrap();
|
||||
let (w, h) = (10usize, 8usize);
|
||||
let mosaic: Vec<f32> = (0..w * h).map(|i| [10.0, 20.0, 30.0][cell[(i / w % 2) * 2 + i % w % 2] as usize]).collect();
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let tile = nx.max(ny);
|
||||
let mut out = vec![0.0; 4 * tile * tile];
|
||||
assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out));
|
||||
let plane = tile * tile;
|
||||
for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() {
|
||||
assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g1_is_beside_the_red_sample_and_g2_under_it() {
|
||||
// RGGB with a distinct value at every site
|
||||
let (w, h) = (4, 4);
|
||||
let mosaic: Vec<f32> = (0..16).map(|i| i as f32).collect();
|
||||
let mut out = vec![0.0; 4 * 4];
|
||||
assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
// cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5
|
||||
assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]);
|
||||
// cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7
|
||||
assert_eq!([out[1], out[5], out[9], out[13]], [2.0, 3.0, 6.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_rejects_wrong_sizes() {
|
||||
let mut out = vec![0.0; 16];
|
||||
assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out));
|
||||
assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! SHA-256 of a model file, streamed (a model can be hundreds of megabytes).
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Lowercase hex SHA-256 of everything `r` yields.
|
||||
pub fn sha256_reader<R: Read>(mut r: R) -> io::Result<String> {
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = r.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(buf.get(..n).unwrap_or(&[]));
|
||||
}
|
||||
Ok(hex(&h.finalize()))
|
||||
}
|
||||
|
||||
/// Lowercase hex SHA-256 of a file.
|
||||
pub fn sha256_file(path: &Path) -> io::Result<String> {
|
||||
sha256_reader(io::BufReader::new(std::fs::File::open(path)?))
|
||||
}
|
||||
|
||||
/// Lowercase hex SHA-256 of `bytes`.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_digests() {
|
||||
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert_eq!(sha256_reader(&b"abc"[..]).unwrap(), sha256_hex(b"abc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The denoise models LightCraft knows about: what each one is, who may use it, and where to fetch it.
|
||||
//!
|
||||
//! None is part of LightCraft. A model with an address here can be downloaded when the user asks and has accepted
|
||||
//! its terms; anything else is brought by the user as an `.onnx` file with a `denoise-model.json` beside it.
|
||||
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
use crate::licence::{Commercial, Licence};
|
||||
|
||||
use crate::manifest::DenoiserManifest;
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
use crate::manifest::{Domain, Gain};
|
||||
|
||||
/// A known model, and how to get it.
|
||||
pub struct Known {
|
||||
pub manifest: DenoiserManifest,
|
||||
/// What to fetch (an archive or the `.onnx` itself); `None` when the user must bring the file.
|
||||
pub download: Option<Download>,
|
||||
/// The `.onnx` inside the downloaded archive (`None`: the download is the `.onnx`).
|
||||
pub entry: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// RawNIND's UtNet2 for Bayer sensors, as published for darktable (`rawdenoise-nind.dtmodel`, a zip with a Bayer and a
|
||||
/// linear model). Its weights are GPL-3.0; the training pictures are CC BY 4.0 and CC0.
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
fn rawnind_bayer() -> Known {
|
||||
let manifest = DenoiserManifest {
|
||||
id: "rawnind-bayer".into(),
|
||||
name: "RawNIND UtNet2 (Bayer)".into(),
|
||||
version: "1.0".into(),
|
||||
licence: Licence {
|
||||
name: "GPL-3.0".into(),
|
||||
commercial: Commercial::Yes,
|
||||
url: Some("https://www.gnu.org/licenses/gpl-3.0.html".into()),
|
||||
notice: "The weights are published under the GNU General Public License v3. LightCraft does not include them: they \
|
||||
are downloaded to your computer at your request and used by it only. If you pass the model file on, the \
|
||||
GPL applies to that copy. Commercial use is subject to the GPL terms, including applicable source, licence and notice obligations on redistribution."
|
||||
.into(),
|
||||
},
|
||||
source: Some("https://github.com/darktable-org/darktable-ai/tree/master/models/rawdenoise-nind".into()),
|
||||
sha256: Some("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901".into()),
|
||||
size_bytes: Some(31_056_425),
|
||||
provenance: "Trained on RawNIND, real photographs of the same scenes taken noisy and clean (images CC BY 4.0 and CC0, \
|
||||
published on Wikimedia Commons and by the University of Louvain). Paper: arXiv 2501.08924."
|
||||
.into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
};
|
||||
let download = Download {
|
||||
id: manifest.id.clone(),
|
||||
url: "https://github.com/darktable-org/darktable-ai/releases/download/release-5.6.0/rawdenoise-nind.dtmodel".into(),
|
||||
file_name: "rawdenoise-nind.dtmodel".into(),
|
||||
size_bytes: 57_700_134,
|
||||
sha256: "d71b5f1e727c85a359e6f74dca9e2016c9d8fc3e2f7ac3e9b347d80ceca969af".into(),
|
||||
};
|
||||
Known { manifest, download: Some(download), entry: Some("model_bayer.onnx") }
|
||||
}
|
||||
|
||||
/// Every model LightCraft knows.
|
||||
pub fn all() -> Vec<Known> {
|
||||
#[cfg(feature = "rawnind-model")]
|
||||
{
|
||||
vec![rawnind_bayer()]
|
||||
}
|
||||
#[cfg(not(feature = "rawnind-model"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The known model with this id.
|
||||
pub fn find(id: &str) -> Option<Known> {
|
||||
all().into_iter().find(|k| k.manifest.id == id)
|
||||
}
|
||||
|
||||
/// The known model whose `.onnx` has this SHA-256.
|
||||
pub fn by_sha256(sha256: &str) -> Option<DenoiserManifest> {
|
||||
all().into_iter().map(|k| k.manifest).find(|m| m.sha256.as_deref() == Some(sha256))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::validate;
|
||||
|
||||
#[test]
|
||||
fn known_models_are_valid_unique_and_downloaded_over_https() {
|
||||
let all = all();
|
||||
assert_eq!(all.is_empty(), !cfg!(feature = "rawnind-model"));
|
||||
validate(&rawnind_bayer().manifest).unwrap();
|
||||
for (i, k) in all.iter().enumerate() {
|
||||
validate(&k.manifest).unwrap();
|
||||
assert!(all.iter().skip(i + 1).all(|o| o.manifest.id != k.manifest.id), "unique ids");
|
||||
assert!(!k.manifest.licence.name.is_empty() && !k.manifest.licence.notice.is_empty(), "terms are shown before use");
|
||||
if let Some(d) = &k.download {
|
||||
assert_eq!(d.id, k.manifest.id);
|
||||
assert!(d.url.starts_with("https://") && d.sha256.len() == 64 && d.size_bytes > 0);
|
||||
// a name that cannot leave the staging folder
|
||||
assert!(!d.file_name.contains(['/', '\\']) && !d.file_name.starts_with('.'));
|
||||
}
|
||||
// a model that may not be used commercially is never offered for download
|
||||
if k.manifest.licence.commercial == Commercial::No {
|
||||
assert!(k.download.is_none(), "{}", k.manifest.id);
|
||||
}
|
||||
}
|
||||
assert_eq!(find("rawnind-bayer").is_some(), cfg!(feature = "rawnind-model"));
|
||||
assert!(find("nothing").is_none());
|
||||
assert_eq!(
|
||||
by_sha256("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901").map(|m| m.id),
|
||||
if cfg!(feature = "rawnind-model") { Some("rawnind-bayer".into()) } else { None }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Download {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
/// The name the file is kept under while it waits for the user to accept its terms.
|
||||
pub file_name: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub fn host(url: &str) -> &str {
|
||||
url.strip_prefix("https://").and_then(|r| r.split('/').next()).unwrap_or("")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Model metadata, bounded archive/cache formats and Bayer tiling, independent of inference.
|
||||
//! Used by featureless engine and web builds; no neural-network or matrix backend is linked.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
pub mod archive;
|
||||
pub mod bayer;
|
||||
pub mod hash;
|
||||
pub mod known;
|
||||
pub mod licence;
|
||||
pub mod manifest;
|
||||
pub mod product;
|
||||
pub mod run;
|
||||
pub mod tiles;
|
||||
|
||||
pub use manifest::{DenoiserManifest, Domain, Gain, ManifestError};
|
||||
pub use run::{Control, Error, Params, TileRunner, denoise_bayer};
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Commercial {
|
||||
Yes,
|
||||
/// Research or personal use only. Never bundled or redistributed by LightCraft.
|
||||
No,
|
||||
/// The licence or the training data is unclear: the user is told so before installing.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Licence {
|
||||
/// SPDX id or a short name ("MIT", "Apache-2.0", "InsightFace non-commercial research").
|
||||
pub name: String,
|
||||
pub commercial: Commercial,
|
||||
pub url: Option<String>,
|
||||
/// Plain-language terms shown before the model is enabled.
|
||||
pub notice: String,
|
||||
}
|
||||
|
||||
impl Default for Licence {
|
||||
fn default() -> Self {
|
||||
Self { name: String::new(), commercial: Commercial::Unknown, url: None, notice: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
let mut bytes = id.bytes();
|
||||
let first_ok = bytes.next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
first_ok && id.len() <= 64 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! A denoise model's manifest: what the model takes and gives, how big its tiles are and who may use it, as
|
||||
//! plain data.
|
||||
//!
|
||||
//! It comes from a `denoise-model.json` next to the `.onnx` file or from the user's `catalog.json`. Both are
|
||||
//! untrusted: [`validate`] must pass before a manifest is used, and [`parse`] applies it after reading JSON of
|
||||
//! bounded size. The licence, id and address rules are the face models' (`crate::licence`).
|
||||
|
||||
use crate::licence::{Licence, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Largest `denoise-model.json` we read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
/// Largest model file we accept (bytes).
|
||||
pub const MAX_MODEL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// What the model takes and what it gives.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Domain {
|
||||
/// The sensor's Bayer mosaic (black subtracted, white at 1, not white balanced) packed as four planes
|
||||
/// `[R, G1, G2, B]` of its 2 × 2 cells, forced to RGGB; gives camera RGB at the mosaic's resolution, so
|
||||
/// the picture is denoised and demosaiced in one step and must not be demosaiced again.
|
||||
BayerToRgb,
|
||||
}
|
||||
|
||||
/// How the model's output scale is brought back to the input's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Gain {
|
||||
/// The output already has the input's scale.
|
||||
None,
|
||||
/// The output has a scale of its own, `nominal` times the input's (a million, for one model) and a little
|
||||
/// different from tile to tile: each tile is scaled so that its channel means equal the input's. A tile too
|
||||
/// dark to measure, or whose measured scale is further than `maxDeviation` (a fraction, 0.05 = 5 %) from
|
||||
/// `nominal`, is scaled as if it were within that limit.
|
||||
MatchMean { nominal: f32, max_deviation: f32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DenoiserManifest {
|
||||
/// Lowercase letters, digits, `.` `_` `-`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub licence: Licence,
|
||||
/// Where the weights come from (an `https://` page or file), for the user's information.
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Lowercase hex SHA-256 of the `.onnx` file, when known.
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size_bytes: Option<u64>,
|
||||
/// What the model was trained on, in plain words; says "undisclosed" when it is.
|
||||
#[serde(default)]
|
||||
pub provenance: String,
|
||||
pub domain: Domain,
|
||||
/// Side of the square the model runs on, in packed cells (the file's fixed input size).
|
||||
pub tile: u32,
|
||||
/// Cells neighbouring tiles share; the picture is blended across them.
|
||||
pub overlap: u32,
|
||||
pub gain: Gain,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("`{0}` is not valid: {1}")]
|
||||
Field(&'static str, String),
|
||||
#[error("not a denoise-model manifest: {0}")]
|
||||
Parse(String),
|
||||
#[error("a manifest may be at most {MAX_MANIFEST_BYTES} bytes")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn bad<T>(field: &'static str, why: impl Into<String>) -> Result<T, ManifestError> {
|
||||
Err(ManifestError::Field(field, why.into()))
|
||||
}
|
||||
|
||||
fn text(field: &'static str, s: &str, min: usize, max: usize) -> Result<(), ManifestError> {
|
||||
let n = s.chars().count();
|
||||
if n < min || n > max {
|
||||
return bad(field, format!("must be {min} to {max} characters"));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && c != '\n') {
|
||||
return bad(field, "must not contain control characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn url(field: &'static str, u: &Option<String>) -> Result<(), ManifestError> {
|
||||
match u {
|
||||
None => Ok(()),
|
||||
Some(u) if u.len() <= 500 && u.starts_with("https://") && !u.chars().any(|c| c.is_control() || c.is_whitespace()) => Ok(()),
|
||||
Some(_) => bad(field, "must be an https:// address of at most 500 characters"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a manifest from outside before trusting it.
|
||||
pub fn validate(m: &DenoiserManifest) -> Result<(), ManifestError> {
|
||||
if !valid_id(&m.id) {
|
||||
return bad("id", "1 to 64 lowercase letters, digits, `.`, `_` or `-`, starting with a letter or digit");
|
||||
}
|
||||
text("name", &m.name, 1, 80)?;
|
||||
text("version", &m.version, 1, 40)?;
|
||||
text("provenance", &m.provenance, 0, 1000)?;
|
||||
text("licence.name", &m.licence.name, 0, 80)?;
|
||||
text("licence.notice", &m.licence.notice, 0, 2000)?;
|
||||
url("licence.url", &m.licence.url)?;
|
||||
url("source", &m.source)?;
|
||||
if let Some(h) = &m.sha256
|
||||
&& !(h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
|
||||
{
|
||||
return bad("sha256", "must be 64 lowercase hex digits");
|
||||
}
|
||||
if let Some(n) = m.size_bytes
|
||||
&& (n == 0 || n > MAX_MODEL_BYTES)
|
||||
{
|
||||
return bad("sizeBytes", format!("must be between 1 and {MAX_MODEL_BYTES}"));
|
||||
}
|
||||
// a U-Net with four poolings wants a multiple of 16; nothing smaller than 64 or larger than 2048 is a tile
|
||||
if !(64..=2048).contains(&m.tile) || !m.tile.is_multiple_of(16) {
|
||||
return bad("tile", "must be a multiple of 16 from 64 to 2048");
|
||||
}
|
||||
// tiles must overlap by less than half, or one tile's blend ramps would run into its other neighbour's
|
||||
if !m.overlap.is_multiple_of(2) || m.overlap < 16 || m.overlap.saturating_mul(2) >= m.tile {
|
||||
return bad("overlap", "must be even, at least 16 and less than half the tile");
|
||||
}
|
||||
if let Gain::MatchMean { nominal, max_deviation } = m.gain {
|
||||
if !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal)) {
|
||||
return bad("gain.nominal", "must be between 0.000001 and 1000000000000");
|
||||
}
|
||||
if !(max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation)) {
|
||||
return bad("gain.maxDeviation", "must be between 0 and 0.5");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read and validate a `denoise-model.json`.
|
||||
pub fn parse(bytes: &[u8]) -> Result<DenoiserManifest, ManifestError> {
|
||||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||
return Err(ManifestError::TooLarge);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
|
||||
let m: DenoiserManifest = serde_json::from_slice(bytes).map_err(|e| ManifestError::Parse(e.to_string()))?;
|
||||
validate(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn sample() -> DenoiserManifest {
|
||||
DenoiserManifest {
|
||||
id: "nind-bayer".into(),
|
||||
name: "Bayer denoiser".into(),
|
||||
version: "1".into(),
|
||||
licence: Licence::default(),
|
||||
source: Some("https://example.org/model.onnx".into()),
|
||||
sha256: None,
|
||||
size_bytes: Some(31_000_000),
|
||||
provenance: "trained on real raw noise pairs".into(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_manifest_round_trips() {
|
||||
let m = sample();
|
||||
let json = serde_json::to_vec(&m).unwrap();
|
||||
assert_eq!(parse(&json).unwrap(), m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_fields_are_named() {
|
||||
let cases: Vec<(&str, Box<dyn Fn(&mut DenoiserManifest)>)> = vec![
|
||||
("id", Box::new(|m| m.id = "../evil".into())),
|
||||
("id", Box::new(|m| m.id = String::new())),
|
||||
("name", Box::new(|m| m.name = "x\u{7}".into())),
|
||||
("source", Box::new(|m| m.source = Some("http://insecure".into()))),
|
||||
("sha256", Box::new(|m| m.sha256 = Some("XYZ".into()))),
|
||||
("sizeBytes", Box::new(|m| m.size_bytes = Some(0))),
|
||||
("tile", Box::new(|m| m.tile = 500)),
|
||||
("tile", Box::new(|m| m.tile = 4096)),
|
||||
("overlap", Box::new(|m| m.overlap = 300)),
|
||||
("overlap", Box::new(|m| m.overlap = 63)),
|
||||
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN })),
|
||||
("gain.maxDeviation", Box::new(|m| m.gain = Gain::MatchMean { nominal: 1.0, max_deviation: 2.0 })),
|
||||
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: 0.0, max_deviation: 0.1 })),
|
||||
("gain.nominal", Box::new(|m| m.gain = Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 })),
|
||||
];
|
||||
for (field, edit) in cases {
|
||||
let mut m = sample();
|
||||
edit(&mut m);
|
||||
match validate(&m) {
|
||||
Err(ManifestError::Field(f, _)) => assert_eq!(f, field),
|
||||
other => panic!("{field}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_bounded_and_tolerant() {
|
||||
assert_eq!(parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]), Err(ManifestError::TooLarge));
|
||||
assert!(matches!(parse(b"{ nope"), Err(ManifestError::Parse(_))));
|
||||
assert!(matches!(parse(b""), Err(ManifestError::Parse(_))));
|
||||
let mut with_bom = vec![0xEF, 0xBB, 0xBF];
|
||||
with_bom.extend(serde_json::to_vec(&sample()).unwrap());
|
||||
assert!(parse(&with_bom).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
//! The denoised picture as cached data, and the blend the Amount slider controls.
|
||||
//!
|
||||
//! A product is camera RGB at the sensor's resolution (what the model gave, before the raw's own opcodes and
|
||||
//! crop), kept as half floats in strips of rows, each strip byte-shuffled and deflated with its own checksum.
|
||||
//! It is derived data: any problem reading it (missing, truncated, corrupt, made for another photo or model) is
|
||||
//! just a cache miss, never a panic, and the picture can be made again.
|
||||
//!
|
||||
//! File: `LCDN`, version, header length, header JSON (`width`, `height`, `strip`, `key`), then for each strip its
|
||||
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
|
||||
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"LCDN";
|
||||
const VERSION: u8 = 1;
|
||||
const MAX_HEADER: usize = 4096;
|
||||
const MAX_SIDE: usize = 1 << 17;
|
||||
/// Most pixels a product may have (the denoiser's own limit, [`crate::run::MAX_PIXELS`]).
|
||||
const MAX_PIXELS: usize = crate::run::MAX_PIXELS;
|
||||
/// Rows per strip: about a megapixel of three planes at 24 MP widths.
|
||||
const STRIP_ROWS: usize = 128;
|
||||
/// Bumped when the meaning of a product changes (included in keys by the caller).
|
||||
pub const ALGORITHM: u32 = 1;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProductError {
|
||||
#[error("the denoised picture could not be read or written: {0}")]
|
||||
Io(String),
|
||||
#[error("not a usable denoised picture: {0}")]
|
||||
Format(String),
|
||||
/// A product made from something else (another photo state, model or version).
|
||||
#[error("the denoised picture is out of date")]
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ProductError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ProductError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub strip: usize,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, ProductError> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| ProductError::Format("not enough memory for the denoised picture".into()))?;
|
||||
v.resize(n, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `f32` as IEEE half-float bits, rounded to nearest even; beyond the range it saturates, NaN becomes 0.
|
||||
pub fn to_f16(v: f32) -> u16 {
|
||||
if !v.is_finite() {
|
||||
return if v.is_nan() {
|
||||
0
|
||||
} else if v > 0.0 {
|
||||
0x7bff
|
||||
} else {
|
||||
0xfbff
|
||||
};
|
||||
}
|
||||
let b = v.to_bits();
|
||||
let sign = ((b >> 16) & 0x8000) as u16;
|
||||
let exp = ((b >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let man = b & 0x7f_ffff;
|
||||
if exp >= 31 {
|
||||
return sign | 0x7bff;
|
||||
}
|
||||
if exp <= 0 {
|
||||
if exp < -10 {
|
||||
return sign;
|
||||
}
|
||||
let man = man | 0x80_0000;
|
||||
let shift = (14 - exp) as u32;
|
||||
let (half, rem, halfway) = (man >> shift, man & ((1u32 << shift) - 1), 1u32 << (shift - 1));
|
||||
let mut h = half as u16;
|
||||
if rem > halfway || (rem == halfway && h & 1 == 1) {
|
||||
h += 1;
|
||||
}
|
||||
return sign | h;
|
||||
}
|
||||
let mut h = (((exp as u32) << 10) | (man >> 13)) as u16;
|
||||
let rem = man & 0x1fff;
|
||||
if rem > 0x1000 || (rem == 0x1000 && h & 1 == 1) {
|
||||
h += 1;
|
||||
}
|
||||
if h >= 0x7c00 {
|
||||
h = 0x7bff;
|
||||
}
|
||||
sign | h
|
||||
}
|
||||
|
||||
/// Half-float bits as `f32`.
|
||||
pub fn from_f16(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||
let exp = i32::from((h >> 10) & 0x1f);
|
||||
let man = f32::from(h & 0x3ff);
|
||||
match exp {
|
||||
0 => sign * man * 2f32.powi(-24),
|
||||
31 => sign * 65504.0,
|
||||
_ => sign * (1.0 + man / 1024.0) * 2f32.powi(exp - 15),
|
||||
}
|
||||
}
|
||||
|
||||
fn crc(bytes: &[u8]) -> u32 {
|
||||
// CRC-32 (IEEE), bitwise: strips are small and this runs once per strip
|
||||
let mut table = [0u32; 256];
|
||||
for (i, t) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*t = c;
|
||||
}
|
||||
!bytes.iter().fold(!0u32, |c, &b| table[((c ^ u32::from(b)) & 0xff) as usize] ^ (c >> 8))
|
||||
}
|
||||
|
||||
/// One strip as bytes: for each plane (R, G, B) the low bytes of its half floats, then the high bytes.
|
||||
fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Result<Vec<u8>, ProductError> {
|
||||
let n = (y1 - y0) * img.width;
|
||||
let mut raw = allocate(6 * n, 0u8)?;
|
||||
let rows = img.data.get(y0 * img.width..y1 * img.width).unwrap_or(&[]);
|
||||
for (i, px) in rows.iter().enumerate() {
|
||||
for (c, &v) in px.iter().enumerate() {
|
||||
let h = to_f16(v);
|
||||
if let Some(lo) = raw.get_mut(2 * c * n + i) {
|
||||
*lo = h as u8;
|
||||
}
|
||||
if let Some(hi) = raw.get_mut((2 * c + 1) * n + i) {
|
||||
*hi = (h >> 8) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(miniz_oxide::deflate::compress_to_vec(&raw, 3))
|
||||
}
|
||||
|
||||
/// Write `img` to `path` (through a temporary file next to it, so a reader never sees half a product).
|
||||
pub fn write(path: &Path, img: &Rgb32f, key: &str) -> Result<(), ProductError> {
|
||||
let pixels = img.width.checked_mul(img.height).filter(|&n| n <= MAX_PIXELS);
|
||||
if img.width == 0 || img.height == 0 || img.width > MAX_SIDE || img.height > MAX_SIDE || pixels != Some(img.data.len()) {
|
||||
return Err(ProductError::Format("the picture has no usable size".into()));
|
||||
}
|
||||
let strips: Vec<(usize, usize)> = (0..img.height).step_by(STRIP_ROWS).map(|y| (y, (y + STRIP_ROWS).min(img.height))).collect();
|
||||
let packed: Vec<Vec<u8>> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect::<Result<_, _>>()?;
|
||||
let header = serde_json::to_vec(&Header { width: img.width, height: img.height, strip: STRIP_ROWS, key: key.to_string() })
|
||||
.map_err(|e| ProductError::Format(e.to_string()))?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let result = (|| -> Result<(), ProductError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
|
||||
f.write_all(MAGIC)?;
|
||||
f.write_all(&[VERSION, 0, 0, 0])?;
|
||||
f.write_all(&(header.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&header)?;
|
||||
for p in &packed {
|
||||
f.write_all(&(p.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&crc(p).to_le_bytes())?;
|
||||
}
|
||||
for p in &packed {
|
||||
f.write_all(p)?;
|
||||
}
|
||||
f.flush()?;
|
||||
drop(f);
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The parsed start of a product file: header, each strip's (compressed length, CRC-32), and the file length the
|
||||
/// start says the whole file has.
|
||||
struct Start {
|
||||
header: Header,
|
||||
table: Vec<(usize, u32)>,
|
||||
expected_len: u64,
|
||||
}
|
||||
|
||||
fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
|
||||
let mut fixed = [0u8; 12];
|
||||
r.read_exact(&mut fixed)?;
|
||||
if fixed.get(0..4) != Some(&MAGIC[..]) || fixed.get(4) != Some(&VERSION) {
|
||||
return Err(ProductError::Format("not a denoised picture of this version".into()));
|
||||
}
|
||||
let len = fixed.get(8..12).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).unwrap_or(u32::MAX) as usize;
|
||||
if len > MAX_HEADER {
|
||||
return Err(ProductError::Format("the header is too large".into()));
|
||||
}
|
||||
let mut hb = allocate(len, 0u8)?;
|
||||
r.read_exact(&mut hb)?;
|
||||
let h: Header = serde_json::from_slice(&hb).map_err(|e| ProductError::Format(e.to_string()))?;
|
||||
let pixels = h.width.saturating_mul(h.height);
|
||||
if h.width == 0 || h.height == 0 || h.width > MAX_SIDE || h.height > MAX_SIDE || pixels > MAX_PIXELS || h.strip != STRIP_ROWS {
|
||||
return Err(ProductError::Format("the size is not usable".into()));
|
||||
}
|
||||
let n = h.height.div_ceil(h.strip);
|
||||
let mut table = Vec::with_capacity(n);
|
||||
let mut total = 12 + len as u64 + 8 * n as u64;
|
||||
for _ in 0..n {
|
||||
let mut e = [0u8; 8];
|
||||
r.read_exact(&mut e)?;
|
||||
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
|
||||
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
|
||||
// a strip never compresses to much more than its raw size
|
||||
if l > 12 * h.strip * h.width + 4096 {
|
||||
return Err(ProductError::Format("a strip is larger than it can be".into()));
|
||||
}
|
||||
total += l as u64;
|
||||
table.push((l, c));
|
||||
}
|
||||
Ok(Start { header: h, table, expected_len: total })
|
||||
}
|
||||
|
||||
/// The header of the product at `path`, when it is one.
|
||||
pub fn read_header_at(path: &Path) -> Result<Header, ProductError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
Ok(read_start(&mut f)?.header)
|
||||
}
|
||||
|
||||
/// Whether `path` holds a complete-looking product made from `key` (the header and the file's length are checked;
|
||||
/// strips are checked when read).
|
||||
pub fn is_current(path: &Path, key: &str) -> bool {
|
||||
let check = || -> Result<bool, ProductError> {
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let s = read_start(&mut f)?;
|
||||
Ok(s.header.key == key && std::fs::metadata(path)?.len() == s.expected_len)
|
||||
};
|
||||
check().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A rectangle of a product, in the product's pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// Half floats as `f32`, for all 65536 bit patterns (a table is much faster than computing each).
|
||||
fn f16_table() -> &'static [f32] {
|
||||
static TABLE: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| (0..=u16::MAX).map(from_f16).collect())
|
||||
}
|
||||
|
||||
/// Strips decoded together (they are inflated in parallel, then folded in order).
|
||||
const GROUP: usize = 8;
|
||||
|
||||
/// One strip, checked and inflated: for each plane the low bytes of its half floats, then the high bytes.
|
||||
fn decode_strip(index: usize, packed: &[u8], expect_crc: u32, n: usize) -> Result<Vec<u8>, ProductError> {
|
||||
if crc(packed) != expect_crc {
|
||||
return Err(ProductError::Format(format!("strip {index} is damaged")));
|
||||
}
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(packed, 6 * n).map_err(|e| ProductError::Format(format!("strip {index}: {e:?}")))?;
|
||||
if raw.len() != 6 * n {
|
||||
return Err(ProductError::Format(format!("strip {index} has the wrong size")));
|
||||
}
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Read the product at `path`, made from `key`, with every `factor × factor` block of pixels averaged into one
|
||||
/// (`factor` 1 = full size). The result is camera RGB.
|
||||
///
|
||||
/// With a `window`, only that rectangle is read and the blocks start at its corner; only whole blocks are made, so
|
||||
/// the result is `window.width / factor × window.height / factor` (the same blocks a binned development of the same
|
||||
/// crop makes). Without one the whole product is read and blocks at the right and bottom edges may be smaller.
|
||||
///
|
||||
/// With `clip` (white is 1), a block in which any sample of a colour is at or above it takes that colour's
|
||||
/// maximum instead of the mean, so a clipped highlight stays clipped for highlight reconstruction.
|
||||
pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize, clip: Option<f32>) -> Result<Rgb32f, ProductError> {
|
||||
let factor = factor.clamp(1, 1 << 12);
|
||||
let mut f = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let Start { header: h, table, expected_len } = read_start(&mut f)?;
|
||||
if h.key != key {
|
||||
return Err(ProductError::Stale);
|
||||
}
|
||||
if std::fs::metadata(path)?.len() != expected_len {
|
||||
return Err(ProductError::Format("the file is not the length its header says".into()));
|
||||
}
|
||||
let w = window.unwrap_or(Window { x: 0, y: 0, width: h.width, height: h.height });
|
||||
let fits = |a: usize, b: usize, limit: usize| a.checked_add(b).is_some_and(|e| e <= limit);
|
||||
if w.width == 0 || w.height == 0 || !fits(w.x, w.width, h.width) || !fits(w.y, w.height, h.height) {
|
||||
return Err(ProductError::Format("the window does not lie inside the picture".into()));
|
||||
}
|
||||
// whole blocks for a window; partial ones at the edges when the whole picture is read
|
||||
let whole = window.is_none();
|
||||
let (ow, oh) = if whole { (w.width.div_ceil(factor), w.height.div_ceil(factor)) } else { (w.width / factor, w.height / factor) };
|
||||
if ow == 0 || oh == 0 {
|
||||
return Err(ProductError::Format("the window is smaller than one block".into()));
|
||||
}
|
||||
// the picture rows and columns the blocks cover
|
||||
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
|
||||
let lut = f16_table();
|
||||
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
|
||||
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
|
||||
lut.get(usize::from(lo) | usize::from(hi) << 8).copied().unwrap_or(0.0)
|
||||
};
|
||||
// which strips are needed, and where each starts in the file (after the header and the strip table)
|
||||
let wanted = |s: usize| s * h.strip < y_end && (s + 1) * h.strip > w.y;
|
||||
let (mut sum, mut peak) = (allocate(ow, [0f32; 3])?, allocate(ow, [f32::MIN; 3])?);
|
||||
let (mut rows_in, mut oy) = (0usize, 0usize);
|
||||
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
|
||||
// skip the strips before the first one wanted
|
||||
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
|
||||
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
|
||||
for group in strips.chunks(GROUP) {
|
||||
let mut packed = Vec::with_capacity(group.len());
|
||||
for &s in group {
|
||||
let Some(&(len, _)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
|
||||
let mut p = allocate(len, 0u8)?;
|
||||
f.read_exact(&mut p)?;
|
||||
packed.push(p);
|
||||
}
|
||||
let decoded: Vec<Result<Vec<u8>, ProductError>> = group
|
||||
.par_iter()
|
||||
.zip(packed.par_iter())
|
||||
.map(|(&s, p)| {
|
||||
let rows = h.height.saturating_sub(s * h.strip).min(h.strip);
|
||||
decode_strip(s, p, table.get(s).map_or(0, |e| e.1), rows * h.width)
|
||||
})
|
||||
.collect();
|
||||
for (&s, raw) in group.iter().zip(decoded) {
|
||||
let raw = raw?;
|
||||
let y0 = s * h.strip;
|
||||
let rows = h.height.saturating_sub(y0).min(h.strip);
|
||||
let n = rows * h.width;
|
||||
for r in 0..rows {
|
||||
let y = y0 + r;
|
||||
if y < w.y || y >= y_end {
|
||||
continue;
|
||||
}
|
||||
let base = r * h.width;
|
||||
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
|
||||
let x0 = w.x + ox * factor;
|
||||
let x1 = (x0 + factor).min(x_end);
|
||||
for x in x0..x1 {
|
||||
for c in 0..3 {
|
||||
let v = value(&raw, c, n, base + x);
|
||||
if let (Some(s_), Some(p_)) = (sm.get_mut(c), pk.get_mut(c)) {
|
||||
*s_ += v;
|
||||
*p_ = p_.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows_in += 1;
|
||||
if rows_in == factor || y + 1 == y_end {
|
||||
for (ox, (sm, pk)) in sum.iter_mut().zip(peak.iter_mut()).enumerate() {
|
||||
// the block's size at the right and bottom edges may be smaller
|
||||
let bw = (x_end - (w.x + ox * factor)).min(factor);
|
||||
let k = 1.0 / (rows_in * bw).max(1) as f32;
|
||||
if let Some(o) = out.data.get_mut(oy * ow + ox) {
|
||||
for c in 0..3 {
|
||||
let (s_, p_) = (sm.get(c).copied().unwrap_or(0.0), pk.get(c).copied().unwrap_or(0.0));
|
||||
if let Some(slot) = o.get_mut(c) {
|
||||
*slot = if factor > 1 && clip.is_some_and(|t| p_ >= t) { p_ } else { s_ * k };
|
||||
}
|
||||
}
|
||||
}
|
||||
*sm = [0.0; 3];
|
||||
*pk = [f32::MIN; 3];
|
||||
}
|
||||
rows_in = 0;
|
||||
oy += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`read_window`] of the whole product: every `factor × factor` block averaged into one.
|
||||
pub fn read(path: &Path, key: &str, factor: usize) -> Result<Rgb32f, ProductError> {
|
||||
read_window(path, key, None, factor, None)
|
||||
}
|
||||
|
||||
/// `base + (denoised − base) · amount` for every pixel (`amount` 0 to 1), in place in `base`. The two pictures must
|
||||
/// be the same size; if they are not, `base` is left as it was and `false` is returned.
|
||||
pub fn blend(base: &mut Rgb32f, denoised: &Rgb32f, amount: f32) -> bool {
|
||||
if base.width != denoised.width || base.height != denoised.height {
|
||||
return false;
|
||||
}
|
||||
let a = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
base.data.par_iter_mut().zip(denoised.data.par_iter()).for_each(|(b, d)| {
|
||||
for c in 0..3 {
|
||||
b[c] += (d[c] - b[c]) * a;
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn oversized_product_header_is_rejected_before_allocating_pixels() {
|
||||
let p = temp("oversized-header");
|
||||
let header = serde_json::to_vec(&Header { width: 10001, height: 10000, strip: STRIP_ROWS, key: "k".into() }).unwrap();
|
||||
let mut bytes = Vec::from(MAGIC.as_slice());
|
||||
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
|
||||
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&header);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
assert!(read_header_at(&p).is_err());
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgb32f {
|
||||
Rgb32f {
|
||||
width: w,
|
||||
height: h,
|
||||
data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.001 + 0.5 * ((i % 7) as f32 / 7.0)]).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_floats_round_trip_closely() {
|
||||
for v in [0.0f32, 1.0, -1.0, 0.5, 0.333_333, 1e-4, 6.1e-5, 3e-6, 123.456, 65000.0, -0.002] {
|
||||
let back = from_f16(to_f16(v));
|
||||
assert!((back - v).abs() <= v.abs() * 0.001 + 1e-7, "{v} -> {back}");
|
||||
}
|
||||
assert_eq!(from_f16(to_f16(1e10)), 65504.0);
|
||||
assert_eq!(to_f16(f32::NAN), 0);
|
||||
assert_eq!(from_f16(to_f16(f32::NEG_INFINITY)), -65504.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_product_survives_a_round_trip_at_full_and_reduced_size() {
|
||||
let (w, h) = (301, 517);
|
||||
let img = picture(w, h);
|
||||
let p = temp("round");
|
||||
write(&p, &img, "k1").unwrap();
|
||||
assert!(is_current(&p, "k1") && !is_current(&p, "k2"));
|
||||
assert_eq!(read_header_at(&p).unwrap().width, w);
|
||||
let full = read(&p, "k1", 1).unwrap();
|
||||
assert_eq!((full.width, full.height), (w, h));
|
||||
for (a, b) in full.data.iter().zip(&img.data) {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 0.002, "{a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
// averaged into blocks of 4: the sizes round up, and the mean of the picture is kept
|
||||
let small = read(&p, "k1", 4).unwrap();
|
||||
assert_eq!((small.width, small.height), (w.div_ceil(4), h.div_ceil(4)));
|
||||
let mean = |i: &Rgb32f| i.data.iter().map(|p| f64::from(p[1])).sum::<f64>() / i.data.len() as f64;
|
||||
assert!((mean(&small) - mean(&img)).abs() < 0.01);
|
||||
// a factor that does not divide the strip height is the same average
|
||||
let odd = read(&p, "k1", 5).unwrap();
|
||||
assert_eq!((odd.width, odd.height), (w.div_ceil(5), h.div_ceil(5)));
|
||||
assert!((mean(&odd) - mean(&img)).abs() < 0.01);
|
||||
assert!(matches!(read(&p, "other", 1), Err(ProductError::Stale)));
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
/// What binning a window of `img` by `k` means, written the slow obvious way.
|
||||
fn blocks(img: &Rgb32f, win: Window, k: usize, clip: Option<f32>) -> Rgb32f {
|
||||
let (ow, oh) = (win.width / k, win.height / k);
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: vec![[0.0; 3]; ow * oh] };
|
||||
for by in 0..oh {
|
||||
for bx in 0..ow {
|
||||
for c in 0..3 {
|
||||
let vals: Vec<f32> = (0..k * k).map(|i| img.data[(win.y + by * k + i / k) * img.width + win.x + bx * k + i % k][c]).collect();
|
||||
let max = vals.iter().copied().fold(f32::MIN, f32::max);
|
||||
out.data[by * ow + bx][c] =
|
||||
if k > 1 && clip.is_some_and(|t| max >= t) { max } else { vals.iter().sum::<f32>() / vals.len() as f32 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_read_as_the_blocks_of_that_crop() {
|
||||
let (w, h) = (301, 517);
|
||||
let mut img = picture(w, h);
|
||||
// a clipped sample in the middle of a block
|
||||
img.data[(130 + 2) * w + 61 + 1] = [1.0, 0.4, 0.4];
|
||||
let p = temp("window");
|
||||
write(&p, &img, "k1").unwrap();
|
||||
let near = |a: &Rgb32f, b: &Rgb32f| {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
for (x, y) in a.data.iter().zip(&b.data) {
|
||||
for c in 0..3 {
|
||||
assert!((x[c] - y[c]).abs() < 0.003, "{x:?} vs {y:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
// windows at odd places, across strip boundaries (128 rows), with factors that do and do not divide them
|
||||
for (win, k) in [
|
||||
(Window { x: 3, y: 5, width: 200, height: 300 }, 1),
|
||||
(Window { x: 3, y: 5, width: 200, height: 300 }, 4),
|
||||
(Window { x: 61, y: 130, width: 121, height: 250 }, 5),
|
||||
(Window { x: 0, y: 0, width: w, height: h }, 2),
|
||||
(Window { x: 1, y: 380, width: 90, height: 137 }, 3),
|
||||
] {
|
||||
let got = read_window(&p, "k1", Some(win), k, None).unwrap();
|
||||
near(&got, &blocks(&img, win, k, None));
|
||||
}
|
||||
// with a clip level the block with the clipped sample keeps its highest value, the others stay means
|
||||
let win = Window { x: 61, y: 130, width: 40, height: 20 };
|
||||
let clipped = read_window(&p, "k1", Some(win), 4, Some(0.99)).unwrap();
|
||||
near(&clipped, &blocks(&img, win, 4, Some(0.99)));
|
||||
assert!(clipped.data[0][0] > 0.99, "the clipped block stays clipped: {:?}", clipped.data[0]);
|
||||
let plain = read_window(&p, "k1", Some(win), 4, None).unwrap();
|
||||
assert!(plain.data[0][0] < 0.99, "an average falls below the clip level");
|
||||
// windows that do not fit, or are smaller than a block, are errors
|
||||
for bad in [
|
||||
Window { x: 0, y: 0, width: 0, height: 5 },
|
||||
Window { x: 290, y: 0, width: 20, height: 5 },
|
||||
Window { x: 0, y: 510, width: 5, height: 10 },
|
||||
Window { x: usize::MAX, y: 0, width: 2, height: 2 },
|
||||
Window { x: 0, y: 0, width: 3, height: 3 },
|
||||
] {
|
||||
assert!(read_window(&p, "k1", Some(bad), 4, None).is_err(), "{bad:?}");
|
||||
}
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smooth_picture_compresses_well() {
|
||||
let img = Rgb32f {
|
||||
width: 512,
|
||||
height: 512,
|
||||
data: (0..512 * 512)
|
||||
.map(|i| {
|
||||
let v = 0.2 + 0.0001 * (i % 512) as f32;
|
||||
[v, v * 0.8, v * 0.6]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let p = temp("size");
|
||||
write(&p, &img, "k").unwrap();
|
||||
let bytes = std::fs::metadata(&p).unwrap().len() as usize;
|
||||
assert!(bytes < 512 * 512 * 6 / 2, "{bytes} bytes for a smooth picture");
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_errors_not_panics() {
|
||||
let img = picture(100, 300);
|
||||
let p = temp("damage");
|
||||
write(&p, &img, "k").unwrap();
|
||||
let good = std::fs::read(&p).unwrap();
|
||||
// truncated at every length
|
||||
for cut in (0..good.len()).step_by(37) {
|
||||
std::fs::write(&p, &good[..cut]).unwrap();
|
||||
assert!(read(&p, "k", 1).is_err(), "cut at {cut}");
|
||||
assert!(!is_current(&p, "k"));
|
||||
}
|
||||
// a flipped byte anywhere is caught by a checksum, the header parse, or the key
|
||||
for at in (0..good.len()).step_by(53) {
|
||||
let mut bad = good.clone();
|
||||
bad[at] ^= 0xff;
|
||||
std::fs::write(&p, &bad).unwrap();
|
||||
let _ = read(&p, "k", 1); // must not panic; most are errors
|
||||
}
|
||||
// a header that claims an absurd size
|
||||
let mut huge = b"LCDN\x01\0\0\0".to_vec();
|
||||
let hdr = br#"{"width":4000000000,"height":4000000000,"strip":128,"key":"k"}"#;
|
||||
huge.extend((hdr.len() as u32).to_le_bytes());
|
||||
huge.extend(hdr);
|
||||
std::fs::write(&p, &huge).unwrap();
|
||||
assert!(matches!(read(&p, "k", 1), Err(ProductError::Format(_))));
|
||||
std::fs::write(&p, b"").unwrap();
|
||||
assert!(read(&p, "k", 1).is_err());
|
||||
assert!(read(&temp("missing"), "k", 1).is_err());
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_rejects_unusable_pictures() {
|
||||
let p = temp("bad");
|
||||
assert!(write(&p, &Rgb32f { width: 0, height: 0, data: vec![] }, "k").is_err());
|
||||
assert!(write(&p, &Rgb32f { width: 3, height: 3, data: vec![[0.0; 3]; 8] }, "k").is_err());
|
||||
assert!(!p.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mixes_by_amount_and_rejects_other_sizes() {
|
||||
let mut base = Rgb32f { width: 2, height: 1, data: vec![[0.0; 3], [1.0; 3]] };
|
||||
let dn = Rgb32f { width: 2, height: 1, data: vec![[1.0; 3], [0.0; 3]] };
|
||||
assert!(blend(&mut base, &dn, 0.25));
|
||||
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
|
||||
assert!(blend(&mut base, &dn, f32::NAN));
|
||||
assert_eq!(base.data, vec![[0.25; 3], [0.75; 3]]);
|
||||
assert!(!blend(&mut base, &Rgb32f { width: 1, height: 1, data: vec![[0.0; 3]] }, 1.0));
|
||||
assert!(blend(&mut base, &dn, 1.0));
|
||||
assert_eq!(base.data, dn.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_matches_the_standard_check_value() {
|
||||
assert_eq!(crc(b"123456789"), 0xcbf4_3926);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Denoising a whole Bayer mosaic with a tile model: cut it into overlapping tiles, run the model on each, bring
|
||||
//! each tile's scale back to the input's, keep clipped highlights as they were, and blend the tiles together.
|
||||
//!
|
||||
//! The model itself is a [`TileRunner`], so everything around it (packing, tiling, scale matching, highlight
|
||||
//! protection, blending, cancelling, progress) is tested without one.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::bayer::{Layout, pack_tile};
|
||||
use crate::manifest::Gain;
|
||||
use crate::tiles::{starts, weight};
|
||||
|
||||
/// Most pixels one picture may have (guards the allocations that follow from its size).
|
||||
pub const MAX_PIXELS: usize = 100_000_000;
|
||||
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
|
||||
const MIN_SIGNAL: f32 = 0.003;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Input(String),
|
||||
#[error("the model gave {0}")]
|
||||
Model(String),
|
||||
#[error("the model could not run: {0}")]
|
||||
Runtime(String),
|
||||
#[error("the model could not be loaded: {0}")]
|
||||
Load(String),
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, Error> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| Error::Input("not enough memory for denoise".into()))?;
|
||||
v.resize(n, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Something that runs the model on one tile.
|
||||
pub trait TileRunner: Sync {
|
||||
/// `input` is the tile as four planes `[R, G1, G2, B]` of `tile × tile` cells (NCHW, batch 1); the answer is
|
||||
/// camera RGB, three planes of `2·tile × 2·tile` pixels (CHW), in the model's own scale.
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error>;
|
||||
}
|
||||
|
||||
pub struct Params {
|
||||
/// Side of the model's square, in cells.
|
||||
pub tile: usize,
|
||||
/// Cells neighbouring tiles share.
|
||||
pub overlap: usize,
|
||||
pub gain: Gain,
|
||||
/// White is 1; samples at or above this (minus a little) are clipped and keep their value.
|
||||
pub clip: Option<f32>,
|
||||
/// Tiles run at once (each model call may itself be single-threaded).
|
||||
pub parallel: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Control<'a> {
|
||||
pub cancel: Option<&'a AtomicBool>,
|
||||
/// `(tiles done, tiles in all)`.
|
||||
pub progress: Option<&'a (dyn Fn(usize, usize) + Sync)>,
|
||||
}
|
||||
|
||||
/// The tile grid of a picture: `(starts across, starts down)`.
|
||||
pub fn grid(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> (Vec<usize>, Vec<usize>) {
|
||||
let (nx, ny) = layout.cells(width, height);
|
||||
(starts(nx, tile, overlap), starts(ny, tile, overlap))
|
||||
}
|
||||
|
||||
/// How many model calls [`denoise_bayer`] makes for a picture.
|
||||
pub fn tile_count(width: usize, height: usize, layout: Layout, tile: usize, overlap: usize) -> usize {
|
||||
let (sx, sy) = grid(width, height, layout, tile, overlap);
|
||||
sx.len().saturating_mul(sy.len())
|
||||
}
|
||||
|
||||
/// Denoise (and demosaic) the normalised mosaic `mosaic` of `width × height` samples, laid out as `layout`.
|
||||
/// Gives camera RGB at the mosaic's resolution.
|
||||
pub fn denoise_bayer(
|
||||
mosaic: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
layout: Layout,
|
||||
runner: &dyn TileRunner,
|
||||
p: &Params,
|
||||
ctl: &Control<'_>,
|
||||
) -> Result<Rgb32f, Error> {
|
||||
let pixels = width
|
||||
.checked_mul(height)
|
||||
.filter(|&n| n > 0 && n <= MAX_PIXELS)
|
||||
.ok_or_else(|| Error::Input("the picture has no size, or is too large".into()))?;
|
||||
if mosaic.len() != pixels {
|
||||
return Err(Error::Input("the mosaic does not have width × height samples".into()));
|
||||
}
|
||||
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
|
||||
return Err(Error::Input("the tile size and overlap do not fit".into()));
|
||||
}
|
||||
if let Gain::MatchMean { nominal, max_deviation } = p.gain
|
||||
&& !(nominal.is_finite() && (1e-6..=1e12).contains(&nominal) && max_deviation.is_finite() && (0.0..=0.5).contains(&max_deviation))
|
||||
{
|
||||
return Err(Error::Input("the model gain is not finite or within its supported range".into()));
|
||||
}
|
||||
if p.clip.is_some_and(|v| !v.is_finite() || v <= 0.0) {
|
||||
return Err(Error::Input("the highlight clip must be finite and positive".into()));
|
||||
}
|
||||
let (nx, ny) = layout.cells(width, height);
|
||||
let (sx, sy) = (starts(nx, p.tile, p.overlap), starts(ny, p.tile, p.overlap));
|
||||
let (cols, rows) = (sx.len(), sy.len());
|
||||
if cols == 0 || rows == 0 {
|
||||
return Err(Error::Input("the picture needs too many tiles".into()));
|
||||
}
|
||||
let tiles: Vec<(usize, usize, bool, bool, bool, bool)> = sy
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(j, &y0)| sx.iter().enumerate().map(move |(i, &x0)| (x0, y0, i > 0, i + 1 < cols, j > 0, j + 1 < rows)))
|
||||
.collect();
|
||||
let total = tiles.len();
|
||||
|
||||
let mut acc = allocate(pixels, [0f32; 3])?;
|
||||
let mut wsum = allocate(nx * ny, 0f32)?;
|
||||
let group = p.parallel.clamp(1, 4);
|
||||
let mut done = 0;
|
||||
// where the time goes, printed under `LIGHTCRAFT_PROFILE`
|
||||
let (mut t_wait, mut t_total) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
|
||||
let began = web_time::Instant::now();
|
||||
|
||||
// One group of tiles at a time: pack, run the model, check and bring each to the input's scale, in parallel.
|
||||
// Blending (sequential: the tiles add into one picture, in tile order so the result does not depend on timing) of
|
||||
// the previous group runs while the next group is on the model, so neither the card nor the cores wait for it.
|
||||
type Tile = (usize, usize, bool, bool, bool, bool);
|
||||
let compute = |chunk: &[Tile]| -> Vec<Result<Vec<f32>, Error>> {
|
||||
chunk
|
||||
.par_iter()
|
||||
.map(|&(x0, y0, bx, ax, by, ay)| {
|
||||
let mut input = allocate(4 * p.tile * p.tile, 0f32)?;
|
||||
if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) {
|
||||
return Err(Error::Input("a tile could not be packed".into()));
|
||||
}
|
||||
let mut out = runner.run(&input)?;
|
||||
let side = 2 * p.tile;
|
||||
if out.len() != 3 * side * side {
|
||||
return Err(Error::Model(format!("{} numbers for a tile, not the {} expected", out.len(), 3 * side * side)));
|
||||
}
|
||||
if out.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Model("numbers that are not finite".into()));
|
||||
}
|
||||
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
|
||||
Ok(out)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut blend = |chunk: &[Tile], outs: Vec<Result<Vec<f32>, Error>>| -> Result<(), Error> {
|
||||
for (&(x0, y0, bx, ax, by, ay), r) in chunk.iter().zip(outs) {
|
||||
blend_tile(&r?, &mut acc, &mut wsum, (width, height), (nx, ny), layout, (x0, y0), p, (bx, ax, by, ay));
|
||||
done += 1;
|
||||
if let Some(f) = ctl.progress {
|
||||
f(done, total);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut previous: Option<(&[Tile], Vec<Result<Vec<f32>, Error>>)> = None;
|
||||
for chunk in tiles.chunks(group) {
|
||||
if ctl.cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let (outs, blended) = rayon::join(
|
||||
|| compute(chunk),
|
||||
|| match previous.take() {
|
||||
Some((c, o)) => {
|
||||
let started = web_time::Instant::now();
|
||||
let r = blend(c, o);
|
||||
(r, started.elapsed())
|
||||
}
|
||||
None => (Ok(()), std::time::Duration::ZERO),
|
||||
},
|
||||
);
|
||||
blended.0?;
|
||||
t_wait += blended.1;
|
||||
previous = Some((chunk, outs));
|
||||
}
|
||||
if let Some((c, o)) = previous.take() {
|
||||
let started = web_time::Instant::now();
|
||||
blend(c, o)?;
|
||||
t_wait += started.elapsed();
|
||||
}
|
||||
t_total += began.elapsed();
|
||||
let started = web_time::Instant::now();
|
||||
// normalise by the weights
|
||||
let (ox, oy) = (layout.position(0, 0).0, layout.position(0, 0).1);
|
||||
acc.par_chunks_mut(width).enumerate().for_each(|(y, row)| {
|
||||
let cy = ((y as isize - oy) / 2) as usize;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let cx = ((x as isize - ox) / 2) as usize;
|
||||
let w = wsum.get(cy * nx + cx).copied().unwrap_or(0.0);
|
||||
if w > 0.0 {
|
||||
*px = px.map(|v| v / w);
|
||||
}
|
||||
}
|
||||
});
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
eprintln!(
|
||||
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
|
||||
group,
|
||||
t_total.as_secs_f64() * 1e3,
|
||||
t_wait.as_secs_f64() * 1e3,
|
||||
started.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
Ok(Rgb32f { width, height, data: acc })
|
||||
}
|
||||
|
||||
/// Bring a tile's output to the input's scale and keep clipped highlights as they were (in place).
|
||||
fn finish_tile(input: &[f32], out: &mut [f32], p: &Params, edges: (bool, bool, bool, bool)) {
|
||||
let t = p.tile;
|
||||
let side = 2 * t;
|
||||
if let Gain::MatchMean { nominal, max_deviation } = p.gain {
|
||||
// the part of the tile whose scale is measured: away from the edges that blend into a neighbour
|
||||
let m = p.overlap / 2;
|
||||
let (i0, i1) = (if edges.0 { m } else { 0 }, t - if edges.1 { m } else { 0 });
|
||||
let (j0, j1) = (if edges.2 { m } else { 0 }, t - if edges.3 { m } else { 0 });
|
||||
let cells = ((i1 - i0) * (j1 - j0)) as f64;
|
||||
let plane_sum = |pl: usize| -> f64 {
|
||||
(j0..j1)
|
||||
.map(|j| input.get(pl * t * t + j * t + i0..pl * t * t + j * t + i1).map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>()))
|
||||
.sum()
|
||||
};
|
||||
let in_mean = [plane_sum(0) / cells, (plane_sum(1) + plane_sum(2)) / (2.0 * cells), plane_sum(3) / cells];
|
||||
for (c, &mean_in) in in_mean.iter().enumerate() {
|
||||
let sum_out: f64 = (2 * j0..2 * j1)
|
||||
.map(|y| {
|
||||
out.get(c * side * side + y * side + 2 * i0..c * side * side + y * side + 2 * i1)
|
||||
.map_or(0.0, |r| r.iter().map(|&v| f64::from(v)).sum::<f64>())
|
||||
})
|
||||
.sum();
|
||||
let mean_out = sum_out / (4.0 * cells);
|
||||
let measured = if mean_in as f32 >= MIN_SIGNAL && mean_out > 0.0 { (mean_out / mean_in) as f32 } else { nominal };
|
||||
let g = measured.clamp(nominal * (1.0 - max_deviation), nominal * (1.0 + max_deviation));
|
||||
if g.is_finite() && g > 0.0 {
|
||||
let inv = 1.0 / g;
|
||||
if let Some(plane) = out.get_mut(c * side * side..(c + 1) * side * side) {
|
||||
plane.iter_mut().for_each(|v| *v *= inv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(clip) = p.clip {
|
||||
protect_clipped(input, out, t, clip);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the mosaic is at (or within a cell of) white, the output becomes the plain cell values again, so a clipped
|
||||
/// area stays exactly as clipped as the camera recorded it (highlight recovery later depends on that).
|
||||
fn protect_clipped(input: &[f32], out: &mut [f32], t: usize, clip: f32) {
|
||||
let side = 2 * t;
|
||||
let plane = t * t;
|
||||
let (Some(r), Some(g1), Some(g2), Some(b)) =
|
||||
(input.get(0..plane), input.get(plane..2 * plane), input.get(2 * plane..3 * plane), input.get(3 * plane..4 * plane))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut m: Vec<f32> = (0..plane).map(|i| r[i].max(g1[i]).max(g2[i]).max(b[i])).collect();
|
||||
if !m.iter().any(|&v| v >= clip) {
|
||||
return;
|
||||
}
|
||||
// 3×3 maximum over cells (separable)
|
||||
let dilate = |src: &[f32], horizontal: bool| -> Vec<f32> {
|
||||
(0..plane)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % t, i / t);
|
||||
let mut best = src[i];
|
||||
for d in [-1isize, 1] {
|
||||
let (nx, ny) = if horizontal { (x as isize + d, y as isize) } else { (x as isize, y as isize + d) };
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < t && (ny as usize) < t {
|
||||
best = best.max(src[ny as usize * t + nx as usize]);
|
||||
}
|
||||
}
|
||||
best
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
m = dilate(&dilate(&m, true), false);
|
||||
let lo = clip - 0.03;
|
||||
for j in 0..t {
|
||||
for i in 0..t {
|
||||
let k = j * t + i;
|
||||
let s = ((m[k] - lo) / (clip - lo)).clamp(0.0, 1.0);
|
||||
if s <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let s = s * s * (3.0 - 2.0 * s);
|
||||
let plain = [r[k], 0.5 * (g1[k] + g2[k]), b[k]];
|
||||
for (c, &pv) in plain.iter().enumerate() {
|
||||
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
if let Some(v) = out.get_mut(c * side * side + (2 * j + sy) * side + 2 * i + sx) {
|
||||
*v += (pv - *v) * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a finished tile to the picture with its blend weights.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_tile(
|
||||
out: &[f32],
|
||||
acc: &mut [[f32; 3]],
|
||||
wsum: &mut [f32],
|
||||
size: (usize, usize),
|
||||
cells: (usize, usize),
|
||||
layout: Layout,
|
||||
at: (usize, usize),
|
||||
p: &Params,
|
||||
edges: (bool, bool, bool, bool),
|
||||
) {
|
||||
let (width, height) = size;
|
||||
let (nx, ny) = cells;
|
||||
let (t, side) = (p.tile, 2 * p.tile);
|
||||
let fade = p.overlap;
|
||||
let wx: Vec<f32> = (0..t).map(|i| weight(i, t, fade, edges.0, edges.1)).collect();
|
||||
let wy: Vec<f32> = (0..t).map(|j| weight(j, t, fade, edges.2, edges.3)).collect();
|
||||
for j in 0..t {
|
||||
let cy = at.1 + j;
|
||||
for i in 0..t {
|
||||
let cx = at.0 + i;
|
||||
// cells past the picture's edge (a picture smaller than one tile is padded) belong to no pixel
|
||||
if cx >= nx || cy >= ny {
|
||||
continue;
|
||||
}
|
||||
let (Some(&a), Some(&b)) = (wx.get(i), wy.get(j)) else { continue };
|
||||
let w = a * b;
|
||||
if let Some(s) = wsum.get_mut(cy * nx + cx) {
|
||||
*s += w;
|
||||
}
|
||||
let (px0, py0) = layout.position(cx as isize, cy as isize);
|
||||
for (sy, sx) in [(0isize, 0isize), (0, 1), (1, 0), (1, 1)] {
|
||||
let (x, y) = (px0 + sx, py0 + sy);
|
||||
if x < 0 || y < 0 || x as usize >= width || y as usize >= height {
|
||||
continue;
|
||||
}
|
||||
let o = (2 * j + sy as usize) * side + 2 * i + sx as usize;
|
||||
let (Some(&r), Some(&g), Some(&bl)) = (out.get(o), out.get(side * side + o), out.get(2 * side * side + o)) else { continue };
|
||||
if let Some(px) = acc.get_mut(y as usize * width + x as usize) {
|
||||
px[0] += w * r;
|
||||
px[1] += w * g;
|
||||
px[2] += w * bl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stand-in model: the cell's samples as an RGB picture at twice the resolution (a plain 2 × 2 "demosaic"),
|
||||
/// times `scale`, so the output has a scale of its own like the real model's.
|
||||
struct Mock {
|
||||
tile: usize,
|
||||
scale: f32,
|
||||
}
|
||||
impl TileRunner for Mock {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let (t, side) = (self.tile, 2 * self.tile);
|
||||
let plane = t * t;
|
||||
let mut out = vec![0.0; 3 * side * side];
|
||||
for j in 0..t {
|
||||
for i in 0..t {
|
||||
let k = j * t + i;
|
||||
let rgb = [input[k], 0.5 * (input[plane + k] + input[2 * plane + k]), input[3 * plane + k]];
|
||||
for (c, v) in rgb.iter().enumerate() {
|
||||
for (sy, sx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
out[c * side * side + (2 * j + sy) * side + 2 * i + sx] = v * self.scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn params(tile: usize, overlap: usize, gain: Gain) -> Params {
|
||||
Params { tile, overlap, gain, clip: None, parallel: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
|
||||
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
|
||||
let model = Mock { tile: 16, scale: 1.0 };
|
||||
for (tile, overlap, gain) in [
|
||||
(usize::MAX, 0, Gain::None),
|
||||
(16, usize::MAX, Gain::None),
|
||||
(16, 0, Gain::MatchMean { nominal: 1.0, max_deviation: f32::NAN }),
|
||||
(16, 0, Gain::MatchMean { nominal: f32::INFINITY, max_deviation: 0.1 }),
|
||||
] {
|
||||
assert!(matches!(denoise_bayer(&[0.5], 1, 1, layout, &model, ¶ms(tile, overlap, gain), &Control::default()), Err(Error::Input(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A smooth picture whose colour at a sample is `base[colour] · (1 + shading)`.
|
||||
fn mosaic(w: usize, h: usize, cell: [u8; 4]) -> Vec<f32> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let c = cell[(y % 2) * 2 + x % 2] as usize;
|
||||
[0.5, 0.3, 0.2][c] * (0.6 + 0.4 * ((x as f32 / 23.0).sin() * (y as f32 / 31.0).cos()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reference(m: &[f32], w: usize, h: usize, layout: Layout) -> Rgb32f {
|
||||
// what the mock gives for the whole picture in one tile
|
||||
let (nx, ny) = layout.cells(w, h);
|
||||
let t = nx.max(ny).div_ceil(16) * 16;
|
||||
denoise_bayer(m, w, h, layout, &Mock { tile: t, scale: 1.0 }, ¶ms(t, 16, Gain::None), &Control::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_blend_to_the_same_picture_as_one_tile_for_every_layout() {
|
||||
for cell in [[0u8, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] {
|
||||
let layout = Layout::from_cell(cell).unwrap();
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, cell);
|
||||
let want = reference(&m, w, h, layout);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &Control::default()).unwrap();
|
||||
assert_eq!((got.width, got.height), (w, h));
|
||||
for (a, b) in got.data.iter().zip(&want.data) {
|
||||
for c in 0..3 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-5, "layout {cell:?}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picture_does_not_depend_on_how_many_tiles_run_at_once_or_how_long_each_takes() {
|
||||
// tiles that finish in a different order every time: blending still adds them in tile order
|
||||
struct Jittery(Mock);
|
||||
impl TileRunner for Jittery {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
let wait = (input.iter().take(64).sum::<f32>() * 1000.0) as u64 % 7;
|
||||
std::thread::sleep(std::time::Duration::from_millis(wait));
|
||||
self.0.run(input)
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 1 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [1, 0, 2, 1]);
|
||||
let run = |parallel: usize| {
|
||||
let mut p = params(32, 8, Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 });
|
||||
p.parallel = parallel;
|
||||
denoise_bayer(&m, w, h, layout, &Jittery(Mock { tile: 32, scale: 1.01e6 }), &p, &Control::default()).unwrap()
|
||||
};
|
||||
let one = run(1);
|
||||
for parallel in [2, 3, 8] {
|
||||
assert_eq!(run(parallel).data, one.data, "{parallel} at once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_tile_or_a_cancel_ends_the_picture_without_a_panic() {
|
||||
struct Fails(Mock, std::sync::atomic::AtomicUsize);
|
||||
impl TileRunner for Fails {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
if self.1.fetch_add(1, Ordering::Relaxed) == 4 {
|
||||
return Err(Error::Runtime("boom".into()));
|
||||
}
|
||||
self.0.run(input)
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let r = denoise_bayer(
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&Fails(Mock { tile: 32, scale: 1.0 }, Default::default()),
|
||||
¶ms(32, 8, Gain::None),
|
||||
&Control::default(),
|
||||
);
|
||||
assert!(matches!(r, Err(Error::Runtime(_))), "{r:?}");
|
||||
let stop = AtomicBool::new(true);
|
||||
let ctl = Control { cancel: Some(&stop), progress: None };
|
||||
let r = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &ctl);
|
||||
assert!(matches!(r, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_of_its_own_is_taken_out_tile_by_tile() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let want = reference(&m, w, h, layout);
|
||||
let gain = Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 };
|
||||
// the scale differs a little from nominal; each tile's own scale is measured and removed
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.02e6 }, ¶ms(32, 8, gain), &Control::default()).unwrap();
|
||||
let mean = |img: &Rgb32f, c: usize| img.data.iter().map(|p| f64::from(p[c])).sum::<f64>() / img.data.len() as f64;
|
||||
for c in 0..3 {
|
||||
let (a, b) = (mean(&got, c), mean(&want, c));
|
||||
assert!((a - b).abs() / b < 0.01, "channel {c}: {a} vs {b}");
|
||||
}
|
||||
// a scale far from nominal is clamped rather than trusted
|
||||
let wild = denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 3.0e6 }, ¶ms(32, 8, gain), &Control::default()).unwrap();
|
||||
assert!(mean(&wild, 1) > 2.0 * mean(&want, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_smaller_than_a_tile_is_padded_by_mirroring() {
|
||||
let layout = Layout { dx: 1, dy: 1 };
|
||||
let (w, h) = (20usize, 14usize);
|
||||
let m = mosaic(w, h, [2, 1, 1, 0]);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Mock { tile: 64, scale: 1.0 }, ¶ms(64, 16, Gain::None), &Control::default()).unwrap();
|
||||
assert_eq!((got.width, got.height), (w, h));
|
||||
assert!(got.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v > 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_highlights_stay_clipped() {
|
||||
// a model that darkens everything by 10 % would pull a clipped plateau below white
|
||||
struct Dim(Mock);
|
||||
impl TileRunner for Dim {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(self.0.run(input)?.into_iter().map(|v| v * 0.9).collect())
|
||||
}
|
||||
}
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if i % w > 40 && i % w < 70 { 1.0 } else { 0.4 }).collect();
|
||||
let mut p = params(32, 8, Gain::None);
|
||||
p.clip = Some(0.99);
|
||||
let got = denoise_bayer(&m, w, h, layout, &Dim(Mock { tile: 32, scale: 1.0 }), &p, &Control::default()).unwrap();
|
||||
let mid = got.data[48 * w + 55];
|
||||
assert!(mid.iter().all(|&v| v > 0.995), "plateau {mid:?}");
|
||||
let dark = got.data[48 * w + 10];
|
||||
assert!(dark.iter().all(|&v| (v - 0.36).abs() < 0.01), "unclipped area still goes through the model: {dark:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_and_progress_work() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let (w, h) = (150usize, 110usize);
|
||||
let m = mosaic(w, h, [0, 1, 1, 2]);
|
||||
let cancel = AtomicBool::new(true);
|
||||
let r = denoise_bayer(
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&Mock { tile: 32, scale: 1.0 },
|
||||
¶ms(32, 8, Gain::None),
|
||||
&Control { cancel: Some(&cancel), progress: None },
|
||||
);
|
||||
assert!(matches!(r, Err(Error::Cancelled)));
|
||||
let seen = std::sync::Mutex::new(Vec::new());
|
||||
let f = |d: usize, n: usize| seen.lock().unwrap().push((d, n));
|
||||
denoise_bayer(&m, w, h, layout, &Mock { tile: 32, scale: 1.0 }, ¶ms(32, 8, Gain::None), &Control { cancel: None, progress: Some(&f) })
|
||||
.unwrap();
|
||||
let seen = seen.into_inner().unwrap();
|
||||
let total = tile_count(w, h, layout, 32, 8);
|
||||
assert!(total > 1);
|
||||
assert_eq!(seen.last(), Some(&(total, total)));
|
||||
assert!(seen.windows(2).all(|p| p[1].0 == p[0].0 + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_are_errors() {
|
||||
let layout = Layout { dx: 0, dy: 0 };
|
||||
let mock = Mock { tile: 32, scale: 1.0 };
|
||||
let p = params(32, 8, Gain::None);
|
||||
assert!(matches!(denoise_bayer(&[], 0, 0, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.0; 10], 4, 4, layout, &mock, &p, &Control::default()), Err(Error::Input(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.0; 16], 4, 4, layout, &mock, ¶ms(32, 16, Gain::None), &Control::default()), Err(Error::Input(_))));
|
||||
struct Bad(Vec<f32>);
|
||||
impl TileRunner for Bad {
|
||||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Cutting a picture into overlapping tiles and putting the results back together.
|
||||
//!
|
||||
//! A model runs on a fixed square, so a picture is covered by tiles spaced evenly along each axis, each sharing
|
||||
//! at least `overlap` cells with its neighbours. Each tile's influence fades towards its own edges (except at the
|
||||
//! picture's edge), and the result is the sum of tile × weight divided by the sum of weights, so any set of
|
||||
//! overlapping tiles blends consistently and a seam between two tiles of slightly different scale is a smooth ramp.
|
||||
|
||||
/// Where the tiles start along one axis of `n` cells: `[0]` when the axis fits one tile (the tile is then padded by
|
||||
/// mirroring), else evenly spaced starts, the first at 0 and the last at `n − tile`, each tile sharing at least
|
||||
/// `overlap` cells with the next.
|
||||
pub fn starts(n: usize, tile: usize, overlap: usize) -> Vec<usize> {
|
||||
if n <= tile || tile == 0 {
|
||||
return vec![0];
|
||||
}
|
||||
let step = tile.saturating_sub(overlap).max(1);
|
||||
// tiles needed so that start k·spacing with spacing ≤ step reaches n − tile
|
||||
let Some(k) = (n - tile).div_ceil(step).checked_add(1).filter(|&k| k <= 1_000_000) else { return Vec::new() };
|
||||
let last = n - tile;
|
||||
(0..k).map(|i| if k == 1 { 0 } else { ((i as u128 * last as u128 + (k - 1) as u128 / 2) / (k - 1) as u128) as usize }).collect()
|
||||
}
|
||||
|
||||
/// Weight of the cell at offset `p` (0-based, inside a tile of `tile` cells) along one axis: 1 in the middle, easing
|
||||
/// down to a sliver over `fade` cells at an edge that has a neighbour, 1 right up to an edge that is the picture's.
|
||||
pub fn weight(p: usize, tile: usize, fade: usize, has_before: bool, has_after: bool) -> f32 {
|
||||
let fade = fade.max(1) as f32;
|
||||
let ramp = |d: usize| {
|
||||
let t = ((d as f32 + 0.5) / fade).min(1.0);
|
||||
// smoothstep, never exactly 0 so that a cell is always covered
|
||||
(t * t * (3.0 - 2.0 * t)).max(1e-3)
|
||||
};
|
||||
let before = if has_before { ramp(p) } else { 1.0 };
|
||||
let after = if has_after { ramp(tile.saturating_sub(1).saturating_sub(p)) } else { 1.0 };
|
||||
before.min(after)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_axes_have_bounded_tile_counts_and_checked_spacing() {
|
||||
assert!(starts(usize::MAX, 1, 0).is_empty());
|
||||
assert!(starts(usize::MAX, 2, usize::MAX).is_empty());
|
||||
let s = starts(usize::MAX, usize::MAX / 2, 0);
|
||||
assert_eq!(s.first(), Some(&0));
|
||||
assert_eq!(s.last(), Some(&(usize::MAX - usize::MAX / 2)));
|
||||
assert!(s.windows(2).all(|s| s[0] < s[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tile_when_it_fits() {
|
||||
assert_eq!(starts(100, 512, 64), vec![0]);
|
||||
assert_eq!(starts(512, 512, 64), vec![0]);
|
||||
assert_eq!(starts(0, 512, 64), vec![0]);
|
||||
assert_eq!(starts(10, 0, 0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_cover_the_axis_and_overlap_enough() {
|
||||
for n in [513usize, 600, 900, 1000, 1024, 1500, 3024, 3600, 5000, 12345] {
|
||||
let s = starts(n, 512, 64);
|
||||
assert_eq!(s[0], 0, "n={n}");
|
||||
assert_eq!(*s.last().unwrap(), n - 512, "n={n}");
|
||||
for w in s.windows(2) {
|
||||
assert!(w[1] > w[0], "starts increase, n={n}");
|
||||
assert!(w[0] + 512 >= w[1] + 64, "at least 64 shared cells between {} and {}, n={n}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_fade_only_towards_neighbours() {
|
||||
let (t, f) = (512, 64);
|
||||
// a lone tile: full weight everywhere
|
||||
assert!((0..t).all(|p| weight(p, t, f, false, false) == 1.0));
|
||||
// the first tile fades on its far side only
|
||||
assert_eq!(weight(0, t, f, false, true), 1.0);
|
||||
assert!(weight(t - 1, t, f, false, true) < 0.01);
|
||||
// the last tile fades on its near side only
|
||||
assert!(weight(0, t, f, true, false) < 0.01);
|
||||
assert_eq!(weight(t - 1, t, f, true, false), 1.0);
|
||||
// a middle tile is 1 in the middle and everywhere positive
|
||||
assert_eq!(weight(t / 2, t, f, true, true), 1.0);
|
||||
assert!((0..t).all(|p| weight(p, t, f, true, true) > 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_overlapping_tiles_blend_to_a_partition_of_unity() {
|
||||
// at every cell of the shared region the normalised weights are in [0, 1] and sum to 1
|
||||
let (t, f) = (512usize, 64usize);
|
||||
let s = starts(900, t, 64);
|
||||
assert_eq!(s.len(), 2);
|
||||
for cell in s[1]..s[0] + t {
|
||||
let a = weight(cell - s[0], t, f, false, true);
|
||||
let b = weight(cell - s[1], t, f, true, false);
|
||||
let (na, nb) = (a / (a + b), b / (a + b));
|
||||
assert!((na + nb - 1.0).abs() < 1e-6 && na >= 0.0 && nb >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "lightcraft-denoise"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft AI denoise (L1): denoise-model manifests, Bayer packing, tiling and blending, the cache file, and pure-Rust CPU inference"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
runtime = ["dep:faer"]
|
||||
# Product-policy switch: absent by default; users may install their own model.
|
||||
rawnind-model = ["lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
# Safe matrix views over the pure-Rust GEMM backend; no BLAS, cc/build scripts
|
||||
# or inner thread pool. Its dependencies include architecture-specific assembly.
|
||||
faer = { package = "faer-core", version = "=0.17.1", default-features = false, features = ["std"], optional = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,18 @@
|
||||
# lightcraft-denoise
|
||||
|
||||
L1 pure-Rust AI RAW denoise primitives and CPU inference. No weights are bundled.
|
||||
See [user workflow and limitations](../../docs/denoise.md).
|
||||
|
||||
`lightcraft-denoise-core` provides metadata, Bayer helpers and cache formats,
|
||||
re-exported here. `manifest` and `known` validate opt-in models; `onnx` reads a bounded operator subset
|
||||
into `net`; `cpu` executes it and `reference` supplies scalar checks. `bayer`, `tiles`
|
||||
and `run` pack sensor data and blend overlapping tiles while preserving clipped
|
||||
highlights. `product` reads/writes the disposable `.lcdn` cache. `runtime` supplies
|
||||
the installation self-test. Synthetic networks are generated in Rust for tests.
|
||||
|
||||
Run `cargo test -p lightcraft-denoise --features runtime`. The CPU backend is optional;
|
||||
featureless and web builds keep the model metadata and cache format without faer/GEMM.
|
||||
The `rawnind-model` feature enables the otherwise empty download catalog and requires
|
||||
an explicit release-policy decision. Real-model tests are ignored by default and
|
||||
require an explicitly supplied local model. The GPU twin is `lightcraft-gpu::nn`;
|
||||
model management, background jobs and export integration live in `lightcraft-engine`.
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Single-thread CPU execution of the checked denoise network.
|
||||
//!
|
||||
//! Convolutions lower at most 256 spatial positions at a time, then use faer's safe views over pure-Rust GEMM.
|
||||
//! No inner thread pool: `run::denoise_bayer` already runs tiles on the application's Rayon pool. Tensor lifetimes
|
||||
//! are planned once; buffers and im2col storage are recycled between calls, independently for concurrent callers.
|
||||
|
||||
use crate::net::{Net, NetError, Op};
|
||||
use crate::run::{Error, TileRunner};
|
||||
use faer::{Parallelism, mat};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const BLOCK: usize = 256;
|
||||
/// Cap all tensor buffers and convolution scratch for one tile together (512 MiB).
|
||||
const MAX_VALUES: usize = 128 * 1024 * 1024;
|
||||
const MAX_TILE: usize = 2048;
|
||||
const MAX_IDLE: usize = 1;
|
||||
|
||||
type Result<T> = std::result::Result<T, NetError>;
|
||||
fn bad(why: &str) -> NetError {
|
||||
NetError::Invalid(why.into())
|
||||
}
|
||||
fn count(c: usize, side: usize) -> Result<usize> {
|
||||
c.checked_mul(side)
|
||||
.and_then(|n| n.checked_mul(side))
|
||||
.filter(|&n| n > 0 && n <= MAX_VALUES)
|
||||
.ok_or_else(|| bad("a tensor has no size or is too large"))
|
||||
}
|
||||
fn zeros(n: usize) -> Result<Vec<f32>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| bad("not enough memory for a denoise tile"))?;
|
||||
v.resize(n, 0.0);
|
||||
Ok(v)
|
||||
}
|
||||
fn sources(op: &Op) -> (usize, Option<usize>) {
|
||||
match op {
|
||||
Op::Conv(c) => (c.src, c.src2),
|
||||
Op::ConvT2(c) => (c.src, None),
|
||||
Op::MaxPool2 { src } | Op::DepthToSpace2 { src } => (*src, None),
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Tensor {
|
||||
slot: usize,
|
||||
side: usize,
|
||||
channels: usize,
|
||||
}
|
||||
struct Plan {
|
||||
net: Net,
|
||||
tensors: Vec<Tensor>,
|
||||
sizes: Vec<usize>,
|
||||
lower: usize,
|
||||
product: usize,
|
||||
up_weights: Vec<Option<Vec<f32>>>,
|
||||
}
|
||||
struct Workspace {
|
||||
buffers: Vec<Vec<f32>>,
|
||||
lower: Vec<f32>,
|
||||
product: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A checked Net on square CHW tiles. Clones share immutable weights and the idle workspace cache.
|
||||
#[derive(Clone)]
|
||||
pub struct NetRunner {
|
||||
plan: Arc<Plan>,
|
||||
idle: Arc<Mutex<Vec<Workspace>>>,
|
||||
}
|
||||
|
||||
impl NetRunner {
|
||||
pub fn new(net: &Net, tile: usize) -> Result<Self> {
|
||||
if tile == 0
|
||||
|| tile > MAX_TILE
|
||||
|| !net.fits_tile(tile)
|
||||
|| net.in_channels != 4
|
||||
|| net.out_channels() != 3
|
||||
|| net.shape(0).is_none_or(|s| s.channels != net.in_channels)
|
||||
{
|
||||
return Err(bad("the tile or Bayer-to-RGB contract does not fit the network"));
|
||||
}
|
||||
let mut last = vec![0; net.ops().len() + 1];
|
||||
for (i, op) in net.ops().iter().enumerate() {
|
||||
let (a, b) = sources(op);
|
||||
for id in std::iter::once(a).chain(b) {
|
||||
*last.get_mut(id).ok_or_else(|| bad("missing tensor"))? = i + 1;
|
||||
}
|
||||
}
|
||||
let mut tensors = vec![Tensor { slot: 0, side: tile, channels: net.in_channels }];
|
||||
let mut sizes = vec![count(net.in_channels, tile)?];
|
||||
let mut busy = vec![true];
|
||||
let (mut lower, mut product) = (0, 0);
|
||||
let mut up_weights = Vec::new();
|
||||
for (i, op) in net.ops().iter().enumerate() {
|
||||
let (src, src2) = sources(op);
|
||||
let a = *tensors.get(src).ok_or_else(|| bad("missing tensor"))?;
|
||||
let (channels, side) = match op {
|
||||
Op::Conv(c) => {
|
||||
if let Some(s) = src2
|
||||
&& tensors.get(s).is_none_or(|b| b.side != a.side)
|
||||
{
|
||||
return Err(bad("a join has different sizes"));
|
||||
}
|
||||
(c.cout, a.side)
|
||||
}
|
||||
Op::ConvT2(c) => (c.cout, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
||||
Op::MaxPool2 { .. } => {
|
||||
if a.side < 2 || !a.side.is_multiple_of(2) {
|
||||
return Err(bad("pooling does not divide the tile"));
|
||||
}
|
||||
(a.channels, a.side / 2)
|
||||
}
|
||||
Op::DepthToSpace2 { .. } => (a.channels / 4, a.side.checked_mul(2).ok_or_else(|| bad("size overflow"))?),
|
||||
};
|
||||
let n = count(channels, side)?;
|
||||
let slot = busy
|
||||
.iter()
|
||||
.zip(&sizes)
|
||||
.enumerate()
|
||||
.filter(|(_, (used, _))| !**used)
|
||||
.min_by_key(|(_, (_, size))| (n.saturating_sub(**size), **size))
|
||||
.map_or(busy.len(), |(i, _)| i);
|
||||
if slot == sizes.len() {
|
||||
sizes.push(n);
|
||||
busy.push(true);
|
||||
} else {
|
||||
let sz = sizes.get_mut(slot).ok_or_else(|| bad("missing buffer"))?;
|
||||
*sz = (*sz).max(n);
|
||||
*busy.get_mut(slot).ok_or_else(|| bad("missing buffer"))? = true;
|
||||
}
|
||||
tensors.push(Tensor { slot, side, channels });
|
||||
// Release sources only after the output has its own buffer (also handles reading the same tensor twice).
|
||||
for id in std::iter::once(src).chain(src2) {
|
||||
if last.get(id) == Some(&(i + 1)) {
|
||||
let t = tensors.get(id).ok_or_else(|| bad("missing tensor"))?;
|
||||
*busy.get_mut(t.slot).ok_or_else(|| bad("missing buffer"))? = false;
|
||||
}
|
||||
}
|
||||
let (k, m, positions) = match op {
|
||||
Op::Conv(c) => (c.cin * c.k * c.k, c.cout, a.side * a.side),
|
||||
Op::ConvT2(c) => (c.cin, c.cout * 4, a.side * a.side),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
lower = lower.max(k.saturating_mul(BLOCK.min(positions)));
|
||||
product = product.max(m.saturating_mul(BLOCK.min(positions)));
|
||||
let packed = if let Op::ConvT2(c) = op {
|
||||
let mut w = zeros(c.weight.len())?;
|
||||
// [cin][cout][dy][dx] -> [cout*4][cin], so an up-convolution is one GEMM and a shuffle.
|
||||
for (ci, row) in c.weight.chunks_exact(c.cout * 4).enumerate() {
|
||||
for (co, &v) in row.iter().enumerate() {
|
||||
*w.get_mut(co * c.cin + ci).ok_or_else(|| bad("invalid transposed weights"))? = v;
|
||||
}
|
||||
}
|
||||
Some(w)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
up_weights.push(packed);
|
||||
}
|
||||
let total = sizes
|
||||
.iter()
|
||||
.try_fold(lower.saturating_add(product), |n, &s| n.checked_add(s))
|
||||
.filter(|&n| n <= MAX_VALUES)
|
||||
.ok_or_else(|| bad("the tile needs too much working memory"))?;
|
||||
let _ = total;
|
||||
Ok(Self { plan: Arc::new(Plan { net: net.clone(), tensors, sizes, lower, product, up_weights }), idle: Arc::new(Mutex::new(Vec::new())) })
|
||||
}
|
||||
/// Immutable data for the GPU runner, without re-reading the model.
|
||||
pub fn net(&self) -> &Net {
|
||||
&self.plan.net
|
||||
}
|
||||
pub fn bytes_per_tile(&self) -> usize {
|
||||
(self.plan.sizes.iter().sum::<usize>() + self.plan.lower + self.plan.product) * 4
|
||||
}
|
||||
fn workspace(&self) -> Result<Workspace> {
|
||||
if let Some(w) = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() {
|
||||
return Ok(w);
|
||||
}
|
||||
Ok(Workspace {
|
||||
buffers: self.plan.sizes.iter().map(|&n| zeros(n)).collect::<Result<_>>()?,
|
||||
lower: zeros(self.plan.lower)?,
|
||||
product: zeros(self.plan.product)?,
|
||||
})
|
||||
}
|
||||
fn execute(&self, input: &[f32], w: &mut Workspace) -> Result<Vec<f32>> {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| bad("no input"))?;
|
||||
let want = count(first.channels, first.side)?;
|
||||
w.buffers.first_mut().and_then(|b| b.get_mut(..want)).ok_or_else(|| bad("missing input buffer"))?.copy_from_slice(input);
|
||||
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
for (i, op) in self.plan.net.ops().iter().enumerate() {
|
||||
let started = web_time::Instant::now();
|
||||
let out = *self.plan.tensors.get(i + 1).ok_or_else(|| bad("missing output tensor"))?;
|
||||
let (before, rest) = w.buffers.split_at_mut_checked(out.slot).ok_or_else(|| bad("missing output buffer"))?;
|
||||
let (dest, after) = rest.split_first_mut().ok_or_else(|| bad("missing output buffer"))?;
|
||||
let get = |id: usize| -> Result<(&[f32], Tensor)> {
|
||||
let t = *self.plan.tensors.get(id).ok_or_else(|| bad("missing source tensor"))?;
|
||||
let data = if t.slot < out.slot {
|
||||
before.get(t.slot)
|
||||
} else {
|
||||
after.get(t.slot.checked_sub(out.slot + 1).ok_or_else(|| bad("aliased buffers"))?)
|
||||
}
|
||||
.and_then(|b| b.get(..count(t.channels, t.side).ok()?))
|
||||
.ok_or_else(|| bad("missing source buffer"))?;
|
||||
Ok((data, t))
|
||||
};
|
||||
let (src, src2) = sources(op);
|
||||
let (a, shape) = get(src)?;
|
||||
let b = src2.map(get).transpose()?.map(|v| v.0);
|
||||
let dest = dest.get_mut(..count(out.channels, out.side)?).ok_or_else(|| bad("short output buffer"))?;
|
||||
match op {
|
||||
Op::Conv(c) => conv(c, a, b, shape.side, dest, &mut w.lower, &mut w.product)?,
|
||||
Op::ConvT2(c) => up(
|
||||
c,
|
||||
a,
|
||||
shape.side,
|
||||
dest,
|
||||
self.plan.up_weights.get(i).and_then(Option::as_deref).ok_or_else(|| bad("missing up weights"))?,
|
||||
&mut w.lower,
|
||||
&mut w.product,
|
||||
)?,
|
||||
Op::MaxPool2 { .. } => pool(a, shape.side, dest)?,
|
||||
Op::DepthToSpace2 { .. } => shuffle(a, shape.side, dest)?,
|
||||
}
|
||||
if dest.iter().any(|v| !v.is_finite()) {
|
||||
return Err(bad("the model produced non-finite values"));
|
||||
}
|
||||
if profile {
|
||||
eprintln!("[denoise-cpu] layer {i}: {}x{}x{} {:.2} ms", out.channels, out.side, out.side, started.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
let t = self.plan.tensors.last().ok_or_else(|| bad("no output"))?;
|
||||
let data = w.buffers.get(t.slot).and_then(|b| b.get(..count(t.channels, t.side).ok()?)).ok_or_else(|| bad("missing result"))?;
|
||||
let mut out = zeros(data.len())?;
|
||||
out.copy_from_slice(data);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
impl TileRunner for NetRunner {
|
||||
fn run(&self, input: &[f32]) -> std::result::Result<Vec<f32>, Error> {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| Error::Runtime("missing input shape".into()))?;
|
||||
let want = count(first.channels, first.side).map_err(|e| Error::Runtime(e.to_string()))?;
|
||||
if input.len() != want || input.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Input("the tile has the wrong size or non-finite values".into()));
|
||||
}
|
||||
let mut w = self.workspace().map_err(|e| Error::Runtime(e.to_string()))?;
|
||||
let result = self.execute(input, &mut w).map_err(|e| Error::Runtime(e.to_string()));
|
||||
let mut idle = self.idle.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if idle.len() < MAX_IDLE {
|
||||
idle.push(w);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
fn activate(x: f32, bias: f32, alpha: Option<f32>) -> f32 {
|
||||
let x = x + bias;
|
||||
if x < 0.0 { x * alpha.unwrap_or(1.0) } else { x }
|
||||
}
|
||||
|
||||
/// All views are over exact, checked slices. The matrix library's assertions therefore never see file-derived sizes.
|
||||
fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) -> Result<()> {
|
||||
if m.checked_mul(k) != Some(w.len()) || k.checked_mul(n) != Some(x.len()) || m.checked_mul(n) != Some(y.len()) || m == 0 || k == 0 || n == 0 {
|
||||
return Err(bad("invalid matrix dimensions"));
|
||||
}
|
||||
faer::mul::matmul(
|
||||
mat::from_row_major_slice_mut::<f32>(y, m, n),
|
||||
mat::from_row_major_slice::<f32>(w, m, k),
|
||||
mat::from_row_major_slice::<f32>(x, k, n),
|
||||
None,
|
||||
1.0,
|
||||
Parallelism::None,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn lower(a: &[f32], b: Option<&[f32]>, side: usize, k: usize, start: usize, n: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let pad = k / 2;
|
||||
let pixels = side * side;
|
||||
// Whole row segments are copied, so bounds checks and edge branches stay out of the multiply loop.
|
||||
for (ci, plane) in a.chunks_exact(pixels).chain(b.into_iter().flat_map(|b| b.chunks_exact(pixels))).enumerate() {
|
||||
for ky in 0..k {
|
||||
for kx in 0..k {
|
||||
let row = (ci * k * k + ky * k + kx) * n;
|
||||
let out = dest.get_mut(row..row + n).ok_or_else(|| bad("short im2col buffer"))?;
|
||||
let mut at = 0;
|
||||
while at < n {
|
||||
let p = start + at;
|
||||
let (y, x) = (p / side, p % side);
|
||||
let len = (side - x).min(n - at);
|
||||
if let Some(yy) = y.checked_add(ky).and_then(|v| v.checked_sub(pad)).filter(|&v| v < side) {
|
||||
let lo = x.max(pad.saturating_sub(kx));
|
||||
let hi = (x + len).min((side + pad).saturating_sub(kx));
|
||||
if lo < hi {
|
||||
let from = yy * side + lo + kx - pad;
|
||||
let to = at + lo - x;
|
||||
out.get_mut(at..to).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
||||
out.get_mut(to..to + hi - lo)
|
||||
.ok_or_else(|| bad("short im2col row"))?
|
||||
.copy_from_slice(plane.get(from..from + hi - lo).ok_or_else(|| bad("short source row"))?);
|
||||
out.get_mut(to + hi - lo..at + len).ok_or_else(|| bad("short im2col padding"))?.fill(0.0);
|
||||
} else {
|
||||
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
||||
}
|
||||
} else {
|
||||
out.get_mut(at..at + len).ok_or_else(|| bad("short im2col row"))?.fill(0.0);
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn conv(c: &crate::net::Conv, a: &[f32], b: Option<&[f32]>, side: usize, dest: &mut [f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
let k = c.cin * c.k * c.k;
|
||||
for start in (0..pixels).step_by(BLOCK) {
|
||||
let n = BLOCK.min(pixels - start);
|
||||
let x = scratch.get_mut(..k * n).ok_or_else(|| bad("short im2col storage"))?;
|
||||
lower(a, b, side, c.k, start, n, x)?;
|
||||
let y = product.get_mut(..c.cout * n).ok_or_else(|| bad("short product storage"))?;
|
||||
multiply(&c.weight, x, y, c.cout, k, n)?;
|
||||
for ((row, &bias), out) in y.chunks_exact(n).zip(&c.bias).zip(dest.chunks_exact_mut(pixels)) {
|
||||
let to = out.get_mut(start..start + n).ok_or_else(|| bad("short convolution output"))?;
|
||||
for (d, &v) in to.iter_mut().zip(row) {
|
||||
*d = activate(v, bias, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn up(c: &crate::net::ConvT2, a: &[f32], side: usize, dest: &mut [f32], weights: &[f32], scratch: &mut [f32], product: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
let out_side = 2 * side;
|
||||
for start in (0..pixels).step_by(BLOCK) {
|
||||
let n = BLOCK.min(pixels - start);
|
||||
let x = scratch.get_mut(..c.cin * n).ok_or_else(|| bad("short up input storage"))?;
|
||||
for (to, from) in x.chunks_exact_mut(n).zip(a.chunks_exact(pixels)) {
|
||||
to.copy_from_slice(from.get(start..start + n).ok_or_else(|| bad("short up input"))?);
|
||||
}
|
||||
let y = product.get_mut(..c.cout * 4 * n).ok_or_else(|| bad("short up product storage"))?;
|
||||
multiply(weights, x, y, c.cout * 4, c.cin, n)?;
|
||||
for (co, (&bias, out)) in c.bias.iter().zip(dest.chunks_exact_mut(4 * pixels)).enumerate() {
|
||||
for d in 0..4 {
|
||||
let row = y.get((4 * co + d) * n..(4 * co + d + 1) * n).ok_or_else(|| bad("short up product"))?;
|
||||
for (j, &v) in row.iter().enumerate() {
|
||||
let p = start + j;
|
||||
let at = (2 * (p / side) + d / 2) * out_side + 2 * (p % side) + d % 2;
|
||||
*out.get_mut(at).ok_or_else(|| bad("short up output"))? = activate(v, bias, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn pool(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let s = side / 2;
|
||||
for (input, out) in a.chunks_exact(side * side).zip(dest.chunks_exact_mut(s * s)) {
|
||||
for (y, row) in out.chunks_exact_mut(s).enumerate() {
|
||||
let lo = input.get(2 * y * side..(2 * y + 1) * side).ok_or_else(|| bad("short pool input"))?;
|
||||
let hi = input.get((2 * y + 1) * side..(2 * y + 2) * side).ok_or_else(|| bad("short pool input"))?;
|
||||
for ((d, lo), hi) in row.iter_mut().zip(lo.as_chunks::<2>().0).zip(hi.as_chunks::<2>().0) {
|
||||
let [a, b] = *lo;
|
||||
let [c, e] = *hi;
|
||||
*d = a.max(b).max(c).max(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn shuffle(a: &[f32], side: usize, dest: &mut [f32]) -> Result<()> {
|
||||
let pixels = side * side;
|
||||
for (input, out) in a.chunks_exact(4 * pixels).zip(dest.chunks_exact_mut(4 * pixels)) {
|
||||
for d in 0..4 {
|
||||
let plane = input.get(d * pixels..(d + 1) * pixels).ok_or_else(|| bad("short shuffle input"))?;
|
||||
for (y, row) in plane.chunks_exact(side).enumerate() {
|
||||
let at = (2 * y + d / 2) * 2 * side;
|
||||
let to = out.get_mut(at..at + 2 * side).ok_or_else(|| bad("short shuffle output"))?;
|
||||
for (to, &v) in to.as_chunks_mut::<2>().0.iter_mut().zip(row) {
|
||||
if let Some(t) = to.get_mut(d % 2) {
|
||||
*t = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,151 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
net::{Conv, ConvT2},
|
||||
reference, synthetic,
|
||||
};
|
||||
|
||||
fn random(n: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed | 1;
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s >> 8) as f32 / (1u32 << 23) as f32 - 1.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn net(tile: usize, ch: u64, depth: usize, seed: u64) -> Net {
|
||||
let p = std::env::temp_dir().join(format!("lc-purecpu-{}-{tile}-{ch}-{depth}-{seed}.onnx", std::process::id()));
|
||||
std::fs::write(&p, synthetic::unet_onnx(tile as u64, ch, depth, seed)).unwrap();
|
||||
let n = crate::onnx::read(&p).unwrap();
|
||||
std::fs::remove_file(p).unwrap();
|
||||
n
|
||||
}
|
||||
fn agree(a: &[f32], b: &[f32]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert!(a.iter().chain(b).all(|v| v.is_finite()));
|
||||
let scale = b.iter().fold(1e-6f32, |m, v| m.max(v.abs()));
|
||||
let error = a.iter().zip(b).fold(0f32, |m, (a, b)| m.max((a - b).abs())) / scale;
|
||||
assert!(error <= 1e-3, "relative maximum error {error:e}");
|
||||
}
|
||||
#[test]
|
||||
fn all_operators_match_scalar_on_random_nets_and_odd_and_tiny_shapes() {
|
||||
for (t, ch, d) in [(1, 3, 0), (3, 5, 0), (5, 7, 0), (6, 3, 1), (20, 5, 2), (36, 9, 2), (64, 8, 3)] {
|
||||
for seed in [1, 73] {
|
||||
let n = net(t, ch, d, seed);
|
||||
let r = NetRunner::new(&n, t).unwrap();
|
||||
let x = random(4 * t * t, seed as u32);
|
||||
let want = reference::run(&n, t, &x).unwrap();
|
||||
agree(&r.run(&x).unwrap(), &want);
|
||||
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_values_fail_before_execution_and_workspace_sizes_are_bounded() {
|
||||
let mut n = net(4, 3, 1, 11);
|
||||
for t in [0, 1, 3, usize::MAX, 4096] {
|
||||
assert!(NetRunner::new(&n, t).is_err());
|
||||
}
|
||||
for ch in [0, 3, usize::MAX] {
|
||||
n.in_channels = ch;
|
||||
assert!(NetRunner::new(&n, 4).is_err());
|
||||
}
|
||||
let n = Net::new(
|
||||
4,
|
||||
vec![
|
||||
Op::Conv(Conv { src: 0, src2: None, cin: 4, cout: 4096, k: 1, weight: vec![0.1; 4 * 4096], bias: vec![0.0; 4096], leaky: None }),
|
||||
Op::Conv(Conv { src: 1, src2: None, cin: 4096, cout: 12, k: 1, weight: vec![0.1; 4096 * 12], bias: vec![0.0; 12], leaky: None }),
|
||||
Op::DepthToSpace2 { src: 2 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(NetRunner::new(&n, 2048).is_err());
|
||||
let ops = (0..100).map(|i| Op::MaxPool2 { src: i }).collect();
|
||||
let n = Net::new(4, ops).unwrap();
|
||||
assert!(NetRunner::new(&n, 16).is_err());
|
||||
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![0.25; 48], bias: vec![0.0; 12], leaky: None };
|
||||
for mode in 0..6 {
|
||||
let mut bad = c.clone();
|
||||
match mode {
|
||||
0 => bad.src = usize::MAX,
|
||||
1 => bad.weight.pop().map(|_| ()).unwrap(),
|
||||
2 => bad.bias[0] = f32::NAN,
|
||||
3 => bad.leaky = Some(f32::INFINITY),
|
||||
4 => bad.cout = usize::MAX,
|
||||
_ => bad.k = usize::MAX,
|
||||
}
|
||||
assert!(Net::new(4, vec![Op::Conv(bad), Op::DepthToSpace2 { src: 1 }]).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bad_tiles_and_arithmetic_overflow_are_errors_and_do_not_poison_reuse() {
|
||||
let c = Conv { src: 0, src2: None, cin: 4, cout: 12, k: 1, weight: vec![f32::MAX; 48], bias: vec![0.0; 12], leaky: None };
|
||||
let n = Net::new(4, vec![Op::Conv(c), Op::DepthToSpace2 { src: 1 }]).unwrap();
|
||||
let r = NetRunner::new(&n, 1).unwrap();
|
||||
assert!(r.run(&[]).is_err());
|
||||
assert!(r.run(&[f32::NAN; 4]).is_err());
|
||||
assert!(r.run(&[f32::INFINITY; 4]).is_err());
|
||||
assert_eq!(r.idle.lock().unwrap().len(), 0, "invalid input never allocates a workspace");
|
||||
assert!(r.run(&[2.0; 4]).is_err());
|
||||
assert_eq!(r.run(&[0.0; 4]).unwrap(), vec![0.0; 12]);
|
||||
// Matrix shape checks are the boundary in front of the dependency's asserting constructors.
|
||||
assert!(multiply(&[], &[], &mut [], usize::MAX, 2, 2).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn shared_runner_serves_concurrent_tiles_without_cross_contamination() {
|
||||
let n = net(12, 5, 1, 97);
|
||||
let r = NetRunner::new(&n, 12).unwrap();
|
||||
let inputs: Vec<_> = (0..8).map(|s| random(4 * 12 * 12, s)).collect();
|
||||
let answers: Vec<_> = inputs.iter().map(|x| reference::run(&n, 12, x).unwrap()).collect();
|
||||
std::thread::scope(|scope| {
|
||||
let mut handles = Vec::new();
|
||||
for (x, want) in inputs.iter().zip(&answers) {
|
||||
let r = &r;
|
||||
handles.push(scope.spawn(move || {
|
||||
for _ in 0..3 {
|
||||
agree(&r.run(x).unwrap(), want);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn join_may_read_the_same_tensor_twice_and_leaky_up_convolution_is_supported() {
|
||||
let c = ConvT2 { src: 0, cin: 4, cout: 3, weight: random(48, 2), bias: vec![0.1, -0.1, 0.0], leaky: Some(0.13) };
|
||||
let n = Net::new(
|
||||
4,
|
||||
vec![
|
||||
Op::ConvT2(c),
|
||||
Op::Conv(Conv { src: 1, src2: Some(1), cin: 6, cout: 3, k: 3, weight: random(162, 3), bias: vec![0.0; 3], leaky: Some(0.0) }),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let x = random(4 * 3 * 3, 7);
|
||||
agree(&NetRunner::new(&n, 3).unwrap().run(&x).unwrap(), &reference::run(&n, 3, &x).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_im2col_clears_all_edge_padding_between_images() {
|
||||
for t in [1, 3, 5, 36] {
|
||||
let n = net(t, 5, 0, 121);
|
||||
let r = NetRunner::new(&n, t).unwrap();
|
||||
for x in [vec![1.0; 4 * t * t], vec![0.0; 4 * t * t], random(4 * t * t, 9)] {
|
||||
agree(&r.run(&x).unwrap(), &reference::run(&n, t, &x).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs LC_DENOISE_MODEL; one tile with LIGHTCRAFT_PROFILE=1"]
|
||||
fn profile_one_real_tile() {
|
||||
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
|
||||
let m = crate::known::find("rawnind-bayer").unwrap().manifest;
|
||||
let r = crate::runtime::CpuRunner::load(std::path::Path::new(&model), &m).unwrap();
|
||||
println!("workspace {:.1} MB", r.bytes_per_tile() as f64 / 1e6);
|
||||
r.run(&crate::runtime::check_tile(512)).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! AI denoise for LightCraft, as a step between the sensor and the demosaic that the rest of the develop
|
||||
//! pipeline never sees: the raw file stays untouched, the denoised picture is cached data that can always be
|
||||
//! made again, and the amount is an ordinary develop setting.
|
||||
//!
|
||||
//! - [`manifest`]: what a denoise model takes and gives, and who may use it (untrusted JSON, validated).
|
||||
//! - [`known`] and [`archive`]: the models LightCraft can fetch, and taking the model file out of the `.zip` it comes in.
|
||||
//! - [`bayer`]: Bayer layouts and the packing of a mosaic into the four planes a model takes.
|
||||
//! - [`tiles`] and [`run`]: cutting a picture into overlapping tiles for a fixed-size model, bringing each tile's
|
||||
//! output scale back to the input's, keeping clipped highlights as they were, and blending the tiles together.
|
||||
//! The model itself is a [`run::TileRunner`], so all of this is tested without one.
|
||||
//! - [`product`]: the cache file the denoised picture is kept in (half floats, compressed in strips) and the
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod cpu;
|
||||
|
||||
pub mod net;
|
||||
pub mod onnx;
|
||||
mod onnx_proto;
|
||||
|
||||
pub mod reference;
|
||||
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod runtime;
|
||||
#[doc(hidden)]
|
||||
pub mod synthetic;
|
||||
|
||||
pub use lightcraft_denoise_core::*;
|
||||
|
||||
pub mod synthetic_proto;
|
||||
@@ -0,0 +1,345 @@
|
||||
//! A denoise network as plain data: the handful of layer kinds a U-Net denoiser is made of, with their weights.
|
||||
//!
|
||||
//! [`onnx`](crate::onnx) reads the file into this description, shared by the pure-Rust CPU and GPU runners.
|
||||
//! Unsupported operators, groups, dilation or activations are reported as errors when loading the model.
|
||||
//!
|
||||
//! The description is checked ([`Net::new`]) because it comes from a stranger's file: channel counts must chain,
|
||||
//! weights must have the sizes the shapes need and be finite, and the depth must stay small.
|
||||
|
||||
/// Most layers a network may have.
|
||||
pub const MAX_OPS: usize = 512;
|
||||
/// Most channels a layer may have.
|
||||
pub const MAX_CHANNELS: usize = 4096;
|
||||
/// Most weights (numbers) a network may have in all.
|
||||
pub const MAX_WEIGHTS: usize = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub enum NetError {
|
||||
/// The model uses something this denoise network description cannot express.
|
||||
#[error("not supported by the denoise runners: {0}")]
|
||||
Unsupported(String),
|
||||
/// The model is not a well-formed network.
|
||||
#[error("not a valid network: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Invalid(why.into()))
|
||||
}
|
||||
|
||||
/// A tensor is named by its number: 0 is the network's input, and layer `i` makes tensor `i + 1`.
|
||||
pub type TensorId = usize;
|
||||
|
||||
/// A convolution with stride 1 and "same" padding (`k` is 1 or 3), over one tensor or two joined along the channels
|
||||
/// (`src` first), then a bias and optionally a leaky ReLU.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Conv {
|
||||
pub src: TensorId,
|
||||
/// A second source, whose channels follow `src`'s (a skip connection's concatenation).
|
||||
pub src2: Option<TensorId>,
|
||||
pub cin: usize,
|
||||
pub cout: usize,
|
||||
pub k: usize,
|
||||
/// `[cout][cin][k][k]`, as ONNX has it.
|
||||
pub weight: Vec<f32>,
|
||||
pub bias: Vec<f32>,
|
||||
/// `Some(alpha)`: a leaky ReLU with this slope follows.
|
||||
pub leaky: Option<f32>,
|
||||
}
|
||||
|
||||
/// A transposed convolution with a 2 × 2 kernel and stride 2: every input pixel becomes a 2 × 2 block.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConvT2 {
|
||||
pub src: TensorId,
|
||||
pub cin: usize,
|
||||
pub cout: usize,
|
||||
/// `[cin][cout][2][2]`, as ONNX has it.
|
||||
pub weight: Vec<f32>,
|
||||
pub bias: Vec<f32>,
|
||||
pub leaky: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Op {
|
||||
Conv(Conv),
|
||||
ConvT2(ConvT2),
|
||||
/// 2 × 2 maximum, stride 2.
|
||||
MaxPool2 {
|
||||
src: TensorId,
|
||||
},
|
||||
/// Depth-to-space with block size 2 in "CRD" order: output channel `c` at `(2y + dy, 2x + dx)` is input channel
|
||||
/// `4c + 2dy + dx` at `(y, x)`.
|
||||
DepthToSpace2 {
|
||||
src: TensorId,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a tensor looks like: its channels, and its resolution as a power of two below the input's (a negative
|
||||
/// level is above it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Shape {
|
||||
pub channels: usize,
|
||||
pub level: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Net {
|
||||
pub in_channels: usize,
|
||||
ops: Vec<Op>,
|
||||
shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl Net {
|
||||
/// Check `ops` (layer `i` makes tensor `i + 1`) over an input of `in_channels` and describe every tensor.
|
||||
pub fn new(in_channels: usize, ops: Vec<Op>) -> Result<Net, NetError> {
|
||||
if in_channels == 0 || in_channels > MAX_CHANNELS {
|
||||
return invalid("the input has no channels or too many");
|
||||
}
|
||||
if ops.is_empty() || ops.len() > MAX_OPS {
|
||||
return invalid("the network has no layers or too many");
|
||||
}
|
||||
let mut shapes = vec![Shape { channels: in_channels, level: 0 }];
|
||||
let mut weights = 0usize;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let shape_of = |t: TensorId| {
|
||||
shapes
|
||||
.get(t)
|
||||
.copied()
|
||||
.filter(|_| t <= i)
|
||||
.ok_or_else(|| NetError::Invalid(format!("layer {i} reads tensor {t}, which does not exist yet")))
|
||||
};
|
||||
let out = match op {
|
||||
Op::Conv(c) => {
|
||||
let a = shape_of(c.src)?;
|
||||
let (cin, level) = match c.src2 {
|
||||
Some(s2) => {
|
||||
let b = shape_of(s2)?;
|
||||
if b.level != a.level {
|
||||
return invalid(format!("layer {i} joins tensors of different resolutions"));
|
||||
}
|
||||
(a.channels + b.channels, a.level)
|
||||
}
|
||||
None => (a.channels, a.level),
|
||||
};
|
||||
if c.cin != cin || c.cout == 0 || c.cout > MAX_CHANNELS || !(c.k == 1 || c.k == 3) {
|
||||
return invalid(format!(
|
||||
"layer {i}: a convolution of {} to {} channels with a {}-wide kernel does not fit",
|
||||
c.cin, c.cout, c.k
|
||||
));
|
||||
}
|
||||
check_weights(
|
||||
i,
|
||||
&c.weight,
|
||||
c.cout.checked_mul(cin).and_then(|n| n.checked_mul(c.k * c.k)),
|
||||
&c.bias,
|
||||
c.cout,
|
||||
c.leaky,
|
||||
&mut weights,
|
||||
)?;
|
||||
Shape { channels: c.cout, level }
|
||||
}
|
||||
Op::ConvT2(c) => {
|
||||
let a = shape_of(c.src)?;
|
||||
if c.cin != a.channels || c.cout == 0 || c.cout > MAX_CHANNELS {
|
||||
return invalid(format!("layer {i}: a transposed convolution of {} to {} channels does not fit", c.cin, c.cout));
|
||||
}
|
||||
check_weights(i, &c.weight, c.cin.checked_mul(c.cout).and_then(|n| n.checked_mul(4)), &c.bias, c.cout, c.leaky, &mut weights)?;
|
||||
Shape { channels: c.cout, level: a.level - 1 }
|
||||
}
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = shape_of(*src)?;
|
||||
Shape { channels: a.channels, level: a.level + 1 }
|
||||
}
|
||||
Op::DepthToSpace2 { src } => {
|
||||
let a = shape_of(*src)?;
|
||||
if !a.channels.is_multiple_of(4) {
|
||||
return invalid(format!("layer {i}: depth-to-space needs a multiple of 4 channels"));
|
||||
}
|
||||
Shape { channels: a.channels / 4, level: a.level - 1 }
|
||||
}
|
||||
};
|
||||
shapes.push(out);
|
||||
}
|
||||
Ok(Net { in_channels, ops, shapes })
|
||||
}
|
||||
|
||||
/// The layers: layer `i` makes tensor `i + 1`.
|
||||
pub fn ops(&self) -> &[Op] {
|
||||
&self.ops
|
||||
}
|
||||
|
||||
/// The shape of tensor `t` (0 is the input).
|
||||
pub fn shape(&self, t: TensorId) -> Option<Shape> {
|
||||
self.shapes.get(t).copied()
|
||||
}
|
||||
|
||||
/// The tensor the network gives (the last layer's).
|
||||
pub fn output(&self) -> TensorId {
|
||||
self.ops.len()
|
||||
}
|
||||
|
||||
pub fn out_channels(&self) -> usize {
|
||||
self.shapes.last().map_or(0, |s| s.channels)
|
||||
}
|
||||
|
||||
/// The deepest resolution level: a tile must be a multiple of `2^this` cells wide.
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.shapes.iter().map(|s| s.level.max(0) as u32).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Can the network run on a `tile × tile` input and give `[out_channels, 2·tile, 2·tile]`? (Pooling must divide
|
||||
/// the tile, and the output must be one level above the input.)
|
||||
pub fn fits_tile(&self, tile: usize) -> bool {
|
||||
let Some(multiple) = 1usize.checked_shl(self.depth()) else { return false };
|
||||
tile > 0 && tile.is_multiple_of(multiple) && self.shapes.last().is_some_and(|s| s.level == -1)
|
||||
}
|
||||
|
||||
/// Multiply-adds for one tile, for estimating time.
|
||||
pub fn macs(&self, tile: usize) -> u64 {
|
||||
let pixels = |level: i32| -> u64 {
|
||||
let side = if level >= 0 {
|
||||
tile.checked_shr(level as u32).unwrap_or(0)
|
||||
} else {
|
||||
tile.saturating_mul(1usize.checked_shl(level.unsigned_abs()).unwrap_or(usize::MAX))
|
||||
};
|
||||
(side as u64).saturating_mul(side as u64)
|
||||
};
|
||||
self.ops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, op)| {
|
||||
let out = self.shapes.get(i + 1).map_or(0, |s| pixels(s.level));
|
||||
match op {
|
||||
Op::Conv(c) => out.saturating_mul((c.cin * c.cout * c.k * c.k) as u64),
|
||||
// every input pixel makes a 2 × 2 block: as many multiply-adds as the 2 × 2-wide kernel has weights
|
||||
Op::ConvT2(c) => pixels(self.shapes.get(c.src).map_or(0, |s| s.level)).saturating_mul((c.cin * c.cout * 4) as u64),
|
||||
_ => 0,
|
||||
}
|
||||
})
|
||||
.fold(0u64, u64::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_weights(
|
||||
layer: usize,
|
||||
weight: &[f32],
|
||||
want: Option<usize>,
|
||||
bias: &[f32],
|
||||
cout: usize,
|
||||
leaky: Option<f32>,
|
||||
total: &mut usize,
|
||||
) -> Result<(), NetError> {
|
||||
if want != Some(weight.len()) || bias.len() != cout {
|
||||
return invalid(format!("layer {layer}: the weights are not the size its shape needs"));
|
||||
}
|
||||
*total = total.saturating_add(weight.len()).saturating_add(bias.len());
|
||||
if *total > MAX_WEIGHTS {
|
||||
return invalid("the network has too many weights");
|
||||
}
|
||||
if weight.iter().chain(bias).any(|v| !v.is_finite()) || leaky.is_some_and(|a| !a.is_finite()) {
|
||||
return invalid(format!("layer {layer}: a weight is not a finite number"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn conv(src: TensorId, src2: Option<TensorId>, cin: usize, cout: usize, k: usize) -> Op {
|
||||
Op::Conv(Conv { src, src2, cin, cout, k, weight: vec![0.1; cout * cin * k * k], bias: vec![0.0; cout], leaky: Some(0.2) })
|
||||
}
|
||||
|
||||
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
|
||||
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
|
||||
}
|
||||
|
||||
/// A two-level network of the shape the denoisers have: 4 → 8 channels, pool, 16 channels, up, join, 12, and the
|
||||
/// 1 × 1 convolution to the colours.
|
||||
fn unet() -> Vec<Op> {
|
||||
vec![
|
||||
conv(0, None, 4, 8, 3), // 1: 8 ch, level 0
|
||||
Op::MaxPool2 { src: 1 }, // 2: level 1
|
||||
conv(2, None, 8, 16, 3), // 3: 16 ch, level 1
|
||||
convt(3, 16, 8), // 4: 8 ch, level 0
|
||||
conv(4, Some(1), 16, 8, 3), // 5: join [up, skip]
|
||||
conv(5, None, 8, 12, 1), // 6: 12 ch
|
||||
Op::DepthToSpace2 { src: 6 }, // 7: 3 ch, level -1
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_networks_and_huge_cost_queries_never_overflow_or_truncate_depth() {
|
||||
let n = Net::new(4, unet()).unwrap();
|
||||
assert_eq!(n.macs(usize::MAX), u64::MAX);
|
||||
assert_eq!(n.macs(0), 0);
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..21 {
|
||||
ops.push(Op::MaxPool2 { src: i });
|
||||
}
|
||||
for i in 21..42 {
|
||||
ops.push(convt(i, 4, 4));
|
||||
}
|
||||
ops.push(conv(42, None, 4, 12, 1));
|
||||
ops.push(Op::DepthToSpace2 { src: 43 });
|
||||
let n = Net::new(4, ops).unwrap();
|
||||
assert!(!n.fits_tile(1 << 20));
|
||||
assert!(n.fits_tile(1 << 21));
|
||||
let _ = n.macs(16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u_net_is_described_with_its_shapes_and_costs() {
|
||||
let net = Net::new(4, unet()).unwrap();
|
||||
assert_eq!(net.out_channels(), 3);
|
||||
assert_eq!(net.shape(3), Some(Shape { channels: 16, level: 1 }));
|
||||
assert_eq!(net.shape(7), Some(Shape { channels: 3, level: -1 }));
|
||||
assert_eq!(net.depth(), 1);
|
||||
assert!(net.fits_tile(64) && !net.fits_tile(63) && !net.fits_tile(0));
|
||||
// 64 × 64 cells: layer 1 = 4096·4·8·9, and so on; just check it is the sum of what the layers cost
|
||||
let t = 64u64;
|
||||
let want = t * t * 4 * 8 * 9 + (t / 2) * (t / 2) * 8 * 16 * 9 + (t / 2) * (t / 2) * 16 * 8 * 4 + t * t * 16 * 8 * 9 + t * t * 8 * 12;
|
||||
assert_eq!(net.macs(64), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_that_does_not_chain_is_refused() {
|
||||
// wrong input channel count for the first convolution
|
||||
assert!(Net::new(3, unet()).is_err());
|
||||
// a layer reading a tensor that does not exist yet
|
||||
let mut ops = unet();
|
||||
ops[2] = conv(9, None, 8, 16, 3);
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// joining tensors of different resolutions
|
||||
let mut ops = unet();
|
||||
ops[4] = conv(3, Some(1), 24, 8, 3);
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// weights of the wrong size, and not finite
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.weight.pop();
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.bias[0] = f32::NAN;
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
// a kernel that is neither 1 nor 3 wide, no layers, depth-to-space on a channel count it cannot split
|
||||
let mut ops = unet();
|
||||
if let Op::Conv(c) = &mut ops[0] {
|
||||
c.k = 5;
|
||||
}
|
||||
assert!(Net::new(4, ops).is_err());
|
||||
assert!(Net::new(4, vec![]).is_err());
|
||||
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }]).is_ok());
|
||||
assert!(Net::new(4, vec![Op::DepthToSpace2 { src: 0 }, Op::DepthToSpace2 { src: 1 }]).is_err());
|
||||
assert!(Net::new(2, vec![Op::DepthToSpace2 { src: 0 }]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_that_does_not_end_one_level_up_does_not_fit_a_tile() {
|
||||
let net = Net::new(4, unet()[..6].to_vec()).unwrap();
|
||||
assert!(!net.fits_tile(64), "no depth-to-space: the output is not at twice the resolution");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Reading an `.onnx` file into a [`Net`] with bounded, pure-Rust protobuf decoding.
|
||||
//!
|
||||
//! Only what the denoise U-Nets are made of is understood: `Conv` (3 × 3 "same" or 1 × 1, stride 1, no groups, no
|
||||
//! dilation) optionally followed by `LeakyRelu`, `ConvTranspose` (2 × 2, stride 2) likewise, `MaxPool` (2 × 2, stride 2),
|
||||
//! a two-input `Concat` on the channels that feeds a convolution, and a final `DepthToSpace` (2, `CRD`). Any other
|
||||
//! operator or attribute is [`NetError::Unsupported`], which is reported to the user by both runners.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::onnx_proto::{self, AttributeProto, NodeProto, TensorProto, ValueInfoProto};
|
||||
use std::io::Read;
|
||||
|
||||
use crate::net::{Conv, ConvT2, MAX_WEIGHTS, Net, NetError, Op, TensorId};
|
||||
|
||||
fn unsupported<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Unsupported(why.into()))
|
||||
}
|
||||
|
||||
fn invalid<T>(why: impl Into<String>) -> Result<T, NetError> {
|
||||
Err(NetError::Invalid(why.into()))
|
||||
}
|
||||
|
||||
/// Read the model at `path`.
|
||||
pub fn read(path: &Path) -> Result<Net, NetError> {
|
||||
let file = std::fs::File::open(path).map_err(|e| NetError::Invalid(e.to_string()))?;
|
||||
let size = usize::try_from(file.metadata().map_err(|e| NetError::Invalid(e.to_string()))?.len())
|
||||
.ok()
|
||||
.filter(|&n| n <= onnx_proto::MAX_BYTES)
|
||||
.ok_or_else(|| NetError::Invalid("model file too large".into()))?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(size).map_err(|_| NetError::Invalid("not enough memory to load the model".into()))?;
|
||||
file.take(onnx_proto::MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| NetError::Invalid(e.to_string()))?;
|
||||
read_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn read_bytes(bytes: &[u8]) -> Result<Net, NetError> {
|
||||
let graph = onnx_proto::decode(bytes)?;
|
||||
|
||||
let initialisers: HashMap<&str, &TensorProto> = graph.initializer.iter().map(|t| (t.name.as_str(), t)).collect();
|
||||
if initialisers.len() != graph.initializer.len() {
|
||||
return invalid("duplicate initializer names");
|
||||
}
|
||||
let inputs: Vec<_> = graph.input.iter().filter(|i| !initialisers.contains_key(i.name.as_str())).collect();
|
||||
let ([input], [output]) = (inputs.as_slice(), graph.output.as_slice()) else { return unsupported("not one input and one output") };
|
||||
let in_channels = match channel_dim(input) {
|
||||
Some(c) => c,
|
||||
None => return unsupported("the input's channel count is not written in the file"),
|
||||
};
|
||||
|
||||
// how many nodes read each name (a graph output counts as a reader)
|
||||
let mut readers: HashMap<&str, usize> = HashMap::new();
|
||||
for n in &graph.node {
|
||||
for i in &n.input {
|
||||
*readers.entry(i.as_str()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
*readers.entry(output.name.as_str()).or_default() += 1;
|
||||
|
||||
let mut ids: HashMap<String, TensorId> = HashMap::new();
|
||||
ids.insert(input.name.clone(), 0);
|
||||
// a Concat is not a layer of its own: the convolution that reads it takes both sources
|
||||
let mut joins: HashMap<String, (TensorId, TensorId)> = HashMap::new();
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
let mut weights_total = 0usize;
|
||||
|
||||
let nodes = &graph.node;
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let Some(node) = nodes.get(i) else { break };
|
||||
i += 1;
|
||||
if node.output.len() != 1 || node.output.first().is_none_or(|n| n.is_empty() || ids.contains_key(n) || joins.contains_key(n)) {
|
||||
return invalid("a layer has missing, repeated or multiple outputs");
|
||||
}
|
||||
let allowed: &[&str] = match node.op_type.as_str() {
|
||||
"Conv" => &["kernel_shape", "pads", "strides", "dilations", "group", "auto_pad"],
|
||||
"ConvTranspose" => &["kernel_shape", "pads", "strides", "dilations", "group", "output_padding", "auto_pad"],
|
||||
"MaxPool" => &["kernel_shape", "pads", "strides", "dilations", "ceil_mode", "storage_order", "auto_pad"],
|
||||
"Concat" => &["axis"],
|
||||
"DepthToSpace" => &["blocksize", "mode"],
|
||||
_ => &[],
|
||||
};
|
||||
if node.attribute.iter().any(|a| !allowed.contains(&a.name.as_str())) {
|
||||
return unsupported("an unknown operator attribute");
|
||||
}
|
||||
let this = ops.len() + 1;
|
||||
let lookup = |name: &str| -> Result<TensorId, NetError> {
|
||||
ids.get(name).copied().ok_or_else(|| NetError::Invalid(format!("`{}` reads `{name}`, which nothing makes", node.op_type)))
|
||||
};
|
||||
// a LeakyRelu straight after this layer, which nothing else reads, is part of it
|
||||
let mut fuse_leaky = |out: &str| -> Result<(Option<f32>, String), NetError> {
|
||||
if let Some(next) = nodes.get(i)
|
||||
&& next.op_type == "LeakyRelu"
|
||||
&& next.input.first().map(String::as_str) == Some(out)
|
||||
&& readers.get(out).copied() == Some(1)
|
||||
{
|
||||
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
|
||||
return invalid("malformed LeakyRelu");
|
||||
}
|
||||
let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
|
||||
i += 1;
|
||||
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
|
||||
return Ok((Some(alpha), name));
|
||||
}
|
||||
Ok((None, out.to_string()))
|
||||
};
|
||||
match node.op_type.as_str() {
|
||||
"Concat" => {
|
||||
if attr(node, "axis").map(|a| a.i) != Some(1) {
|
||||
return unsupported("Concat on another axis than the channels");
|
||||
}
|
||||
let [a, b] = node.input.as_slice() else { return unsupported("Concat of other than two tensors") };
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("Concat without an output".into()))?;
|
||||
if readers.get(out.as_str()).copied() != Some(1) {
|
||||
return unsupported("a Concat that is read by more than one layer");
|
||||
}
|
||||
joins.insert(out.clone(), (lookup(a)?, lookup(b)?));
|
||||
}
|
||||
"Conv" => {
|
||||
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
|
||||
let (cout, cin, kh, kw) = four(&w.0)?;
|
||||
let b = if b.is_empty() { vec![0.0; cout] } else { b };
|
||||
if kh != kw || !(kh == 1 || kh == 3) {
|
||||
return unsupported(format!("a {kh} × {kw} convolution"));
|
||||
}
|
||||
if ints_or(node, "strides", &[1, 1]) != [1, 1]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "group").is_some_and(|a| a.i != 1)
|
||||
{
|
||||
return unsupported("a convolution with strides, dilation or groups");
|
||||
}
|
||||
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [kh as i64, kw as i64]) {
|
||||
return invalid("kernel shape does not match the weights");
|
||||
}
|
||||
let pad = (kh as i64 - 1) / 2;
|
||||
if ints_or(node, "pads", &[0; 4]) != [pad; 4] || attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
|
||||
return unsupported("a convolution that is not padded to keep its size");
|
||||
}
|
||||
let src_name = node.input.first().ok_or_else(|| NetError::Invalid("Conv without an input".into()))?;
|
||||
let (src, src2) = match joins.remove(src_name) {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (lookup(src_name)?, None),
|
||||
};
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("Conv without an output".into()))?;
|
||||
let (leaky, out_name) = fuse_leaky(out)?;
|
||||
ids.insert(out_name, this);
|
||||
ops.push(Op::Conv(Conv { src, src2, cin, cout, k: kh, weight: w.1, bias: b, leaky }));
|
||||
}
|
||||
"ConvTranspose" => {
|
||||
let (w, b) = weights_of(node, &initialisers, &mut weights_total)?;
|
||||
let (cin, cout, kh, kw) = four(&w.0)?;
|
||||
let b = if b.is_empty() { vec![0.0; cout] } else { b };
|
||||
if (kh, kw) != (2, 2)
|
||||
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|
||||
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "group").is_some_and(|a| a.i != 1)
|
||||
|| attr(node, "output_padding").is_some_and(|a| a.ints.iter().any(|&v| v != 0))
|
||||
{
|
||||
return unsupported("a transposed convolution other than 2 × 2 with stride 2");
|
||||
}
|
||||
if attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET") {
|
||||
return unsupported("transposed convolution auto padding");
|
||||
}
|
||||
if attr(node, "kernel_shape").is_some_and(|a| a.ints != [2, 2]) {
|
||||
return invalid("transposed kernel shape does not match the weights");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("ConvTranspose without an output".into()))?;
|
||||
let (leaky, out_name) = fuse_leaky(out)?;
|
||||
ids.insert(out_name, this);
|
||||
ops.push(Op::ConvT2(ConvT2 { src, cin, cout, weight: w.1, bias: b, leaky }));
|
||||
}
|
||||
"MaxPool" => {
|
||||
if ints_or(node, "kernel_shape", &[]) != [2, 2]
|
||||
|| ints_or(node, "strides", &[1, 1]) != [2, 2]
|
||||
|| ints_or(node, "pads", &[0; 4]) != [0; 4]
|
||||
|| ints_or(node, "dilations", &[1, 1]) != [1, 1]
|
||||
|| attr(node, "ceil_mode").is_some_and(|a| a.i != 0)
|
||||
|| attr(node, "storage_order").is_some_and(|a| a.i != 0)
|
||||
|| attr(node, "auto_pad").is_some_and(|a| !a.s.is_empty() && a.s != b"NOTSET")
|
||||
{
|
||||
return unsupported("a max-pool other than 2 × 2 with stride 2");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("MaxPool without an output".into()))?;
|
||||
ids.insert(out.clone(), this);
|
||||
ops.push(Op::MaxPool2 { src });
|
||||
}
|
||||
"DepthToSpace" => {
|
||||
if attr(node, "blocksize").map(|a| a.i) != Some(2) || attr(node, "mode").map(|a| a.s.as_slice()) != Some(&b"CRD"[..]) {
|
||||
return unsupported("depth-to-space other than block size 2 in CRD order");
|
||||
}
|
||||
let src = lookup(node.input.first().map_or("", String::as_str))?;
|
||||
let out = node.output.first().ok_or_else(|| NetError::Invalid("DepthToSpace without an output".into()))?;
|
||||
ids.insert(out.clone(), this);
|
||||
ops.push(Op::DepthToSpace2 { src });
|
||||
}
|
||||
other => return unsupported(format!("the `{other}` operator")),
|
||||
}
|
||||
}
|
||||
if !joins.is_empty() {
|
||||
return unsupported("a Concat whose result no convolution reads");
|
||||
}
|
||||
if ids.get(output.name.as_str()) != Some(&ops.len()) {
|
||||
return unsupported("the output is not the last layer");
|
||||
}
|
||||
Net::new(in_channels, ops)
|
||||
}
|
||||
|
||||
fn attr<'a>(n: &'a NodeProto, name: &str) -> Option<&'a AttributeProto> {
|
||||
n.attribute.iter().find(|a| a.name == name)
|
||||
}
|
||||
|
||||
/// The integer list attribute `name`, or `default` when the node does not have it.
|
||||
fn ints_or(n: &NodeProto, name: &str, default: &[i64]) -> Vec<i64> {
|
||||
attr(n, name).map_or_else(|| default.to_vec(), |a| a.ints.clone())
|
||||
}
|
||||
|
||||
fn four(dims: &[i64]) -> Result<(usize, usize, usize, usize), NetError> {
|
||||
match dims {
|
||||
[a, b, c, d] if dims.iter().all(|&v| v > 0 && v <= 1 << 20) => Ok((*a as usize, *b as usize, *c as usize, *d as usize)),
|
||||
_ => invalid("a weight tensor that is not four-dimensional"),
|
||||
}
|
||||
}
|
||||
|
||||
type Weights = ((Vec<i64>, Vec<f32>), Vec<f32>);
|
||||
|
||||
/// The weight and bias initialisers of a convolution node.
|
||||
fn weights_of(node: &NodeProto, initialisers: &HashMap<&str, &TensorProto>, total: &mut usize) -> Result<Weights, NetError> {
|
||||
let mut get = |idx: usize| -> Result<(Vec<i64>, Vec<f32>), NetError> {
|
||||
let name = node.input.get(idx).ok_or_else(|| NetError::Invalid(format!("`{}` without its weights", node.op_type)))?;
|
||||
let t = initialisers.get(name.as_str()).ok_or_else(|| NetError::Unsupported(format!("weights that are computed (`{name}`)")))?;
|
||||
let values = floats(t)?;
|
||||
*total = total.saturating_add(values.len());
|
||||
if *total > MAX_WEIGHTS {
|
||||
return invalid("the network has too many weights");
|
||||
}
|
||||
Ok((t.dims.clone(), values))
|
||||
};
|
||||
let w = get(1)?;
|
||||
let b = if node.input.len() > 2 {
|
||||
let (dims, values) = get(2)?;
|
||||
if dims.len() != 1 || values.is_empty() {
|
||||
return invalid("a bias tensor must be a nonempty vector");
|
||||
}
|
||||
values
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((w, b))
|
||||
}
|
||||
|
||||
fn floats(t: &TensorProto) -> Result<Vec<f32>, NetError> {
|
||||
if t.data_type != 1 {
|
||||
return unsupported("weights that are not 32-bit floats");
|
||||
}
|
||||
let want: usize = t
|
||||
.dims
|
||||
.iter()
|
||||
.try_fold(1usize, |n, &d| usize::try_from(d).ok().and_then(|d| n.checked_mul(d)))
|
||||
.ok_or_else(|| NetError::Invalid("a tensor with a negative or huge size".into()))?;
|
||||
if want > MAX_WEIGHTS {
|
||||
return invalid("a tensor with too many numbers");
|
||||
}
|
||||
if !t.raw_data.is_empty() && (!t.float_data.is_empty() || !t.raw_data.len().is_multiple_of(4)) {
|
||||
return invalid("ambiguous or truncated tensor data");
|
||||
}
|
||||
let actual = if t.raw_data.is_empty() { t.float_data.len() } else { t.raw_data.len() / 4 };
|
||||
if actual != want {
|
||||
return invalid("tensor data does not match its dimensions");
|
||||
}
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(want).map_err(|_| NetError::Invalid("not enough memory for weights".into()))?;
|
||||
if !t.raw_data.is_empty() {
|
||||
values.extend(t.raw_data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)));
|
||||
} else {
|
||||
values.extend_from_slice(&t.float_data);
|
||||
}
|
||||
if values.len() != want {
|
||||
return invalid(format!("tensor `{}` has {} numbers, not the {want} its shape says", t.name, values.len()));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// The channel count in an input's declared shape `[batch, channels, …]`.
|
||||
fn channel_dim(v: &ValueInfoProto) -> Option<usize> {
|
||||
v.channels
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::synthetic::unet_onnx;
|
||||
|
||||
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("lc-denoise-onnx-{name}-{}.onnx", std::process::id()));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u_net_is_read_with_its_layers_fused() {
|
||||
let (tile, depth) = (32usize, 2usize);
|
||||
let p = temp_model("unet", &unet_onnx(tile as u64, 8, depth, 7));
|
||||
let net = read(&p).unwrap();
|
||||
// per level two convolutions; a pool per level but the last; going up a transposed convolution and two convolutions
|
||||
// per level (the join is part of the first); then the 1 × 1 convolution and depth-to-space
|
||||
assert_eq!(net.ops().len(), 2 * (depth + 1) + depth + depth * 3 + 2);
|
||||
assert!(matches!(net.ops().last(), Some(crate::net::Op::DepthToSpace2 { .. })));
|
||||
assert!(net.ops().iter().any(|o| matches!(o, crate::net::Op::Conv(c) if c.src2.is_some())), "the joins became two-source convolutions");
|
||||
assert!(
|
||||
net.ops().iter().all(|o| !matches!(o, crate::net::Op::Conv(c) if c.leaky.is_none() && c.k == 3)),
|
||||
"every 3 × 3 convolution has its activation"
|
||||
);
|
||||
assert_eq!((net.in_channels, net.out_channels(), net.depth()), (4, 3, 2));
|
||||
assert!(net.fits_tile(tile) && !net.fits_tile(30));
|
||||
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
|
||||
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
|
||||
let n = NodeProto { input: vec!["data".into(), "w".into(), "b".into()], ..Default::default() };
|
||||
for dims in [vec![0], vec![3, 4]] {
|
||||
let b = TensorProto {
|
||||
name: "b".into(),
|
||||
float_data: vec![0.0; dims.iter().product::<i64>() as usize],
|
||||
dims,
|
||||
data_type: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let inits = HashMap::from([("w", &w), ("b", &b)]);
|
||||
assert!(weights_of(&n, &inits, &mut 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_mutated_protobuf_is_bounded_and_never_panics() {
|
||||
let good = unet_onnx(4, 3, 1, 7);
|
||||
for n in (0..good.len()).step_by(31) {
|
||||
let _ = read_bytes(&good[..n]);
|
||||
}
|
||||
for at in (0..good.len()).step_by(37) {
|
||||
let mut bad = good.clone();
|
||||
bad[at] ^= 0xff;
|
||||
let _ = read_bytes(&bad);
|
||||
}
|
||||
assert!(read_bytes(&[0x3a, 0xff, 0xff, 0xff, 0xff, 0x7f]).is_err());
|
||||
assert!(read_bytes(&[0x3a, 0, 0x3a, 0]).is_err());
|
||||
}
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn arbitrary_model_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..4096)) {
|
||||
let _ = read_bytes(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_description_cannot_say_is_unsupported_and_garbage_is_an_error() {
|
||||
// not a model
|
||||
let p = temp_model("junk", b"this is not an onnx file");
|
||||
assert!(matches!(read(&p), Err(NetError::Invalid(_)) | Err(NetError::Unsupported(_))));
|
||||
let _ = std::fs::remove_file(p);
|
||||
// a missing file
|
||||
assert!(read(Path::new("/does/not/exist.onnx")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Bounded, borrowed decoding of the ONNX fields used by denoise, from the public Apache-2.0 schema:
|
||||
//! https://github.com/onnx/onnx/blob/main/onnx/onnx.proto3
|
||||
//! No generated code, protoc, recursive graph decoding, or external tensor files.
|
||||
|
||||
use crate::net::{MAX_OPS, MAX_WEIGHTS, NetError};
|
||||
|
||||
const MAX_ITEMS: usize = MAX_OPS * 4;
|
||||
const MAX_STRING: usize = 4096;
|
||||
pub(super) const MAX_BYTES: usize = MAX_WEIGHTS * 4 + 16 * 1024 * 1024;
|
||||
|
||||
type Result<T> = std::result::Result<T, NetError>;
|
||||
fn bad() -> NetError {
|
||||
NetError::Invalid("malformed or oversized ONNX protobuf".into())
|
||||
}
|
||||
|
||||
struct Fields<'a> {
|
||||
rest: &'a [u8],
|
||||
count: usize,
|
||||
}
|
||||
enum Wire<'a> {
|
||||
Int(u64),
|
||||
Bytes(&'a [u8]),
|
||||
F32(f32),
|
||||
Other,
|
||||
}
|
||||
|
||||
fn varint(rest: &mut &[u8]) -> Result<u64> {
|
||||
let mut value = 0u64;
|
||||
for shift in (0..70).step_by(7) {
|
||||
let (b, tail) = rest.split_first().ok_or_else(bad)?;
|
||||
*rest = tail;
|
||||
if shift == 63 && *b > 1 {
|
||||
return Err(bad());
|
||||
}
|
||||
value |= u64::from(*b & 127) << shift;
|
||||
if b & 128 == 0 {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(bad())
|
||||
}
|
||||
|
||||
impl<'a> Fields<'a> {
|
||||
fn new(rest: &'a [u8]) -> Self {
|
||||
Self { rest, count: 0 }
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
|
||||
let out = self.rest.get(..n).ok_or_else(bad)?;
|
||||
self.rest = self.rest.get(n..).ok_or_else(bad)?;
|
||||
Ok(out)
|
||||
}
|
||||
fn next(&mut self) -> Result<Option<(u32, Wire<'a>)>> {
|
||||
if self.rest.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.count += 1;
|
||||
if self.count > MAX_WEIGHTS {
|
||||
return Err(bad());
|
||||
}
|
||||
let tag = varint(&mut self.rest)?;
|
||||
let number = u32::try_from(tag >> 3).map_err(|_| bad())?;
|
||||
if number == 0 || number >= 1 << 29 {
|
||||
return Err(bad());
|
||||
}
|
||||
let wire = match tag & 7 {
|
||||
0 => Wire::Int(varint(&mut self.rest)?),
|
||||
1 => {
|
||||
self.take(8)?;
|
||||
Wire::Other
|
||||
}
|
||||
2 => {
|
||||
let n = usize::try_from(varint(&mut self.rest)?).map_err(|_| bad())?;
|
||||
Wire::Bytes(self.take(n)?)
|
||||
}
|
||||
5 => {
|
||||
let b: [u8; 4] = self.take(4)?.try_into().map_err(|_| bad())?;
|
||||
Wire::F32(f32::from_le_bytes(b))
|
||||
}
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
Ok(Some((number, wire)))
|
||||
}
|
||||
}
|
||||
|
||||
fn string(b: &[u8]) -> Result<String> {
|
||||
if b.len() > MAX_STRING {
|
||||
return Err(bad());
|
||||
}
|
||||
std::str::from_utf8(b).map(str::to_owned).map_err(|_| bad())
|
||||
}
|
||||
fn push<T>(v: &mut Vec<T>, value: T, cap: usize) -> Result<()> {
|
||||
if v.len() >= cap {
|
||||
return Err(bad());
|
||||
}
|
||||
v.try_reserve(1).map_err(|_| bad())?;
|
||||
v.push(value);
|
||||
Ok(())
|
||||
}
|
||||
fn ints(w: Wire<'_>, out: &mut Vec<i64>, cap: usize) -> Result<()> {
|
||||
match w {
|
||||
Wire::Int(v) => push(out, v as i64, cap),
|
||||
Wire::Bytes(mut b) => {
|
||||
while !b.is_empty() {
|
||||
let v = varint(&mut b)?;
|
||||
push(out, v as i64, cap)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AttributeProto {
|
||||
pub name: String,
|
||||
pub f: f32,
|
||||
pub i: i64,
|
||||
pub s: Vec<u8>,
|
||||
pub ints: Vec<i64>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct NodeProto {
|
||||
pub input: Vec<String>,
|
||||
pub output: Vec<String>,
|
||||
pub op_type: String,
|
||||
pub attribute: Vec<AttributeProto>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct TensorProto<'a> {
|
||||
pub name: String,
|
||||
pub dims: Vec<i64>,
|
||||
pub data_type: i32,
|
||||
pub raw_data: &'a [u8],
|
||||
pub float_data: Vec<f32>,
|
||||
}
|
||||
pub(super) struct ValueInfoProto {
|
||||
pub name: String,
|
||||
pub channels: Option<usize>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Graph<'a> {
|
||||
pub node: Vec<NodeProto>,
|
||||
pub initializer: Vec<TensorProto<'a>>,
|
||||
pub input: Vec<ValueInfoProto>,
|
||||
pub output: Vec<ValueInfoProto>,
|
||||
}
|
||||
|
||||
fn attribute(b: &[u8]) -> Result<AttributeProto> {
|
||||
let mut a = AttributeProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => a.name = string(b)?,
|
||||
(2, Wire::F32(v)) => a.f = v,
|
||||
(3, Wire::Int(v)) => a.i = v as i64,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_STRING {
|
||||
return Err(bad());
|
||||
}
|
||||
a.s = b.to_vec();
|
||||
}
|
||||
(8, w) => ints(w, &mut a.ints, 16)?,
|
||||
(20, Wire::Int(1 | 2 | 3 | 7)) => {}
|
||||
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn node(b: &[u8]) -> Result<NodeProto> {
|
||||
let mut a = NodeProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => push(&mut a.input, string(b)?, 4)?,
|
||||
(2, Wire::Bytes(b)) => push(&mut a.output, string(b)?, 2)?,
|
||||
(4, Wire::Bytes(b)) => a.op_type = string(b)?,
|
||||
(5, Wire::Bytes(b)) => push(&mut a.attribute, attribute(b)?, 16)?,
|
||||
(7, Wire::Bytes(b)) if !b.is_empty() => return Err(NetError::Unsupported("a custom operator domain".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn tensor(b: &[u8]) -> Result<TensorProto<'_>> {
|
||||
let mut a = TensorProto::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, w) => ints(w, &mut a.dims, 8)?,
|
||||
(2, Wire::Int(v)) => a.data_type = i32::try_from(v).map_err(|_| bad())?,
|
||||
(4, Wire::F32(v)) => push(&mut a.float_data, v, MAX_WEIGHTS)?,
|
||||
(4, Wire::Bytes(b)) => {
|
||||
if !b.len().is_multiple_of(4) {
|
||||
return Err(bad());
|
||||
}
|
||||
for chunk in b.as_chunks::<4>().0 {
|
||||
push(&mut a.float_data, f32::from_le_bytes(*chunk), MAX_WEIGHTS)?;
|
||||
}
|
||||
}
|
||||
(8, Wire::Bytes(b)) => a.name = string(b)?,
|
||||
(9, Wire::Bytes(b)) => {
|
||||
if b.len() > MAX_WEIGHTS * 4 {
|
||||
return Err(bad());
|
||||
}
|
||||
a.raw_data = b;
|
||||
}
|
||||
(13, _) | (14, Wire::Int(1..)) => return Err(NetError::Unsupported("external tensor data".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn channel_shape(b: &[u8]) -> Result<Option<usize>> {
|
||||
let mut fields = Fields::new(b);
|
||||
let mut dim = 0;
|
||||
let mut channels = None;
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (1, Wire::Bytes(b)) = (n, w) {
|
||||
if dim >= 8 {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut df = Fields::new(b);
|
||||
while let Some((n, w)) = df.next()? {
|
||||
if let (1, Wire::Int(v)) = (n, w)
|
||||
&& dim == 1
|
||||
{
|
||||
channels = usize::try_from(v).ok().filter(|&v| v > 0);
|
||||
}
|
||||
}
|
||||
dim += 1;
|
||||
}
|
||||
}
|
||||
Ok(channels)
|
||||
}
|
||||
fn channel_type(b: &[u8]) -> Result<Option<usize>> {
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (1, Wire::Bytes(b)) = (n, w) {
|
||||
let mut tf = Fields::new(b);
|
||||
let (mut elem, mut channels) = (0, None);
|
||||
while let Some((n, w)) = tf.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Int(v)) => elem = v,
|
||||
(2, Wire::Bytes(b)) => channels = channel_shape(b)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return Ok(if elem == 1 { channels } else { None });
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn value_info(b: &[u8]) -> Result<ValueInfoProto> {
|
||||
let mut out = ValueInfoProto { name: String::new(), channels: None };
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => out.name = string(b)?,
|
||||
(2, Wire::Bytes(b)) => out.channels = channel_type(b)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn graph(b: &[u8]) -> Result<Graph<'_>> {
|
||||
let mut out = Graph::default();
|
||||
let mut fields = Fields::new(b);
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
match (n, w) {
|
||||
(1, Wire::Bytes(b)) => push(&mut out.node, node(b)?, MAX_ITEMS)?,
|
||||
(5, Wire::Bytes(b)) => push(&mut out.initializer, tensor(b)?, MAX_ITEMS)?,
|
||||
(11, Wire::Bytes(b)) => push(&mut out.input, value_info(b)?, MAX_ITEMS)?,
|
||||
(12, Wire::Bytes(b)) => push(&mut out.output, value_info(b)?, 1)?,
|
||||
(15, _) => return Err(NetError::Unsupported("sparse initializers".into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
pub(super) fn decode(b: &[u8]) -> Result<Graph<'_>> {
|
||||
if b.len() > MAX_BYTES {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut fields = Fields::new(b);
|
||||
let mut out = None;
|
||||
while let Some((n, w)) = fields.next()? {
|
||||
if let (7, Wire::Bytes(b)) = (n, w) {
|
||||
if out.is_some() {
|
||||
return Err(bad());
|
||||
}
|
||||
out = Some(graph(b)?);
|
||||
}
|
||||
}
|
||||
out.ok_or_else(bad)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! A plain-loop interpreter of a [`Net`], slow and obviously right: the reference the parser and the GPU runner are
|
||||
//! tested against (tract's answer on the real model is the other). It is not used on photos.
|
||||
|
||||
use crate::net::{Net, NetError, Op, TensorId};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// A tensor in `[channels][side][side]` order.
|
||||
struct T {
|
||||
channels: usize,
|
||||
side: usize,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
|
||||
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
|
||||
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
|
||||
return Err(NetError::Invalid("the tile does not fit the network".into()));
|
||||
}
|
||||
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
|
||||
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
|
||||
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
|
||||
};
|
||||
for op in net.ops() {
|
||||
let out = match op {
|
||||
Op::Conv(c) => {
|
||||
let a = get(&tensors, c.src)?;
|
||||
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
|
||||
let side = tensors[a].side;
|
||||
let pad = (c.k - 1) / 2;
|
||||
let mut data = vec![0f32; c.cout * side * side];
|
||||
// the input channels in order: the first source's, then the second's
|
||||
let planes: Vec<&[f32]> =
|
||||
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
let mut sum = c.bias[co];
|
||||
for (ci, plane) in planes.iter().enumerate() {
|
||||
for ky in 0..c.k {
|
||||
for kx in 0..c.k {
|
||||
let (yy, xx) = ((y + ky) as isize - pad as isize, (x + kx) as isize - pad as isize);
|
||||
if yy < 0 || xx < 0 || yy >= side as isize || xx >= side as isize {
|
||||
continue;
|
||||
}
|
||||
sum += plane[yy as usize * side + xx as usize] * c.weight[((co * c.cin + ci) * c.k + ky) * c.k + kx];
|
||||
}
|
||||
}
|
||||
}
|
||||
data[(co * side + y) * side + x] = leaky(sum, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels: c.cout, side, data }
|
||||
}
|
||||
Op::ConvT2(c) => {
|
||||
let a = get(&tensors, c.src)?;
|
||||
let side = tensors[a].side;
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; c.cout * out_side * out_side];
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let mut sum = c.bias[co];
|
||||
for ci in 0..c.cin {
|
||||
sum += tensors[a].data[(ci * side + y) * side + x] * c.weight[((ci * c.cout + co) * 2 + dy) * 2 + dx];
|
||||
}
|
||||
data[(co * out_side + 2 * y + dy) * out_side + 2 * x + dx] = leaky(sum, c.leaky);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels: c.cout, side: out_side, data }
|
||||
}
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
|
||||
let mut data = vec![0f32; channels * side * side];
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
let at = |dy: usize, dx: usize| tensors[a].data[(ch * 2 * side + 2 * y + dy) * 2 * side + 2 * x + dx];
|
||||
data[(ch * side + y) * side + x] = at(0, 0).max(at(0, 1)).max(at(1, 0)).max(at(1, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels, side, data }
|
||||
}
|
||||
Op::DepthToSpace2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; channels * out_side * out_side];
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
data[(ch * out_side + 2 * y + dy) * out_side + 2 * x + dx] =
|
||||
tensors[a].data[((4 * ch + 2 * dy + dx) * side + y) * side + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T { channels, side: out_side, data }
|
||||
}
|
||||
};
|
||||
tensors.push(out);
|
||||
}
|
||||
tensors.pop().map(|t| t.data).ok_or_else(|| NetError::Invalid("no layers".into()))
|
||||
}
|
||||
|
||||
fn leaky(v: f32, alpha: Option<f32>) -> f32 {
|
||||
match alpha {
|
||||
Some(a) if v < 0.0 => v * a,
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference as a [`TileRunner`] (for tests of the code around the model).
|
||||
pub struct ReferenceRunner {
|
||||
pub net: Net,
|
||||
pub tile: usize,
|
||||
}
|
||||
|
||||
impl TileRunner for ReferenceRunner {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Model loading, a shared installation self-test, and deterministic test tiles.
|
||||
//! `CpuRunner` is the pure-Rust executor; scalar operator tests live in `reference`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::manifest::{DenoiserManifest, Gain};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// What `self_test` found.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelfTest {
|
||||
pub ok: bool,
|
||||
/// Milliseconds to load and optimise the model.
|
||||
pub load_ms: f64,
|
||||
/// Median milliseconds to run one tile.
|
||||
pub tile_ms: f64,
|
||||
/// Megapixels of picture one tile covers, for estimating a whole photo's time.
|
||||
pub tile_megapixels: f64,
|
||||
/// The model's output scale relative to its input on the test picture (what `Gain::MatchMean` takes out).
|
||||
pub scale: f64,
|
||||
/// Each check: what was checked and whether it held.
|
||||
pub checks: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
/// The default pure-Rust CPU model, with the same manifest and installation self-test as the reference.
|
||||
#[derive(Clone)]
|
||||
pub struct CpuRunner {
|
||||
runner: crate::cpu::NetRunner,
|
||||
manifest: DenoiserManifest,
|
||||
load_ms: f64,
|
||||
}
|
||||
impl CpuRunner {
|
||||
pub fn load(path: &Path, manifest: &DenoiserManifest) -> Result<Self, Error> {
|
||||
crate::manifest::validate(manifest).map_err(|e| Error::Load(e.to_string()))?;
|
||||
let started = Instant::now();
|
||||
let net = crate::onnx::read(path).map_err(|e| Error::Load(e.to_string()))?;
|
||||
let runner = crate::cpu::NetRunner::new(&net, manifest.tile as usize).map_err(|e| Error::Load(e.to_string()))?;
|
||||
Ok(Self { runner, manifest: manifest.clone(), load_ms: started.elapsed().as_secs_f64() * 1e3 })
|
||||
}
|
||||
pub fn manifest(&self) -> &DenoiserManifest {
|
||||
&self.manifest
|
||||
}
|
||||
pub fn net(&self) -> &crate::net::Net {
|
||||
self.runner.net()
|
||||
}
|
||||
pub fn bytes_per_tile(&self) -> usize {
|
||||
self.runner.bytes_per_tile()
|
||||
}
|
||||
pub fn self_test(&self) -> SelfTest {
|
||||
self_test(self, &self.manifest, self.load_ms)
|
||||
}
|
||||
}
|
||||
impl TileRunner for CpuRunner {
|
||||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, Error> {
|
||||
self.runner.run(input)
|
||||
}
|
||||
}
|
||||
fn self_test(runner: &dyn TileRunner, manifest: &DenoiserManifest, load_ms: f64) -> SelfTest {
|
||||
let t = manifest.tile as usize;
|
||||
let mut checks: Vec<(String, bool)> = Vec::new();
|
||||
let (input, base) = test_tile(t);
|
||||
let (mut times, mut scale) = (Vec::new(), 0.0f64);
|
||||
let mut run = || {
|
||||
let started = Instant::now();
|
||||
let r = runner.run(&input);
|
||||
times.push(started.elapsed().as_secs_f64() * 1000.0);
|
||||
r
|
||||
};
|
||||
let (a, b) = (run(), run());
|
||||
match (&a, &b) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
checks.push((format!("gives {} finite numbers per tile", a.len()), true));
|
||||
checks.push((
|
||||
"gives the same answer for the same tile".into(),
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4 * x.abs().max(y.abs()).max(1e-9)),
|
||||
));
|
||||
let side = 2 * t;
|
||||
let mean_in = base.iter().map(|&v| f64::from(v)).sum::<f64>() / base.len().max(1) as f64;
|
||||
let mean_out = a.iter().map(|&v| f64::from(v)).sum::<f64>() / a.len().max(1) as f64;
|
||||
scale = if mean_in > 0.0 { mean_out / mean_in } else { 0.0 };
|
||||
if let Gain::MatchMean { nominal, max_deviation } = manifest.gain {
|
||||
let ratio = scale / f64::from(nominal);
|
||||
let tolerance = f64::from(max_deviation).max(0.05) * 4.0;
|
||||
checks.push((format!("its scale is the {nominal:.3e} the description says (found {scale:.3e})"), (ratio - 1.0).abs() <= tolerance));
|
||||
}
|
||||
// noise left after bringing the answer back to the input's scale, against the noise that went in
|
||||
let g = if scale > 0.0 { 1.0 / scale } else { 1.0 };
|
||||
let (mut noise_in, mut noise_out, mut n_in, mut n_out) = (0.0f64, 0.0f64, 0usize, 0usize);
|
||||
for (i, (&v, &s)) in input.iter().zip(base.iter().cycle()).enumerate() {
|
||||
// centre of each plane only: edges are the model's guess
|
||||
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
|
||||
if x >= t / 4 && x < 3 * t / 4 && y >= t / 4 && y < 3 * t / 4 {
|
||||
noise_in += f64::from(v - s).powi(2);
|
||||
n_in += 1;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
for y in side / 4..3 * side / 4 {
|
||||
for x in side / 4..3 * side / 4 {
|
||||
if let (Some(&o), Some(&s)) = (a.get(c * side * side + y * side + x), base.get((y / 2) * t + x / 2)) {
|
||||
noise_out += (f64::from(o) * g - f64::from(s)).powi(2);
|
||||
n_out += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (rms_in, rms_out) = ((noise_in / n_in.max(1) as f64).sqrt(), (noise_out / n_out.max(1) as f64).sqrt());
|
||||
checks.push((format!("takes noise down (from {rms_in:.4} to {rms_out:.4})"), rms_out < 0.8 * rms_in));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => checks.push((format!("runs: {e}"), false)),
|
||||
}
|
||||
times.sort_by(|x, y| x.total_cmp(y));
|
||||
SelfTest {
|
||||
ok: !checks.is_empty() && checks.iter().all(|(_, ok)| *ok),
|
||||
load_ms,
|
||||
tile_ms: times.get(times.len() / 2).copied().unwrap_or(0.0),
|
||||
tile_megapixels: (2 * t * 2 * t) as f64 / 1e6,
|
||||
scale,
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic test tile: four planes of a smooth gradient with noise on top, and the gradient alone for one
|
||||
/// plane (`tile × tile`).
|
||||
pub fn test_tile(t: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
if t == 0 || t > 2048 {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let mut seed = 0x2545_f491u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
let base: Vec<f32> = (0..t * t).map(|i| 0.12 + 0.3 * (((i % t) + (i / t)) as f32 / (2 * t) as f32)).collect();
|
||||
let input = (0..4 * t * t)
|
||||
.map(|i| {
|
||||
// sum of four uniforms: close to Gaussian, deviation 0.0577 × 2 before scaling
|
||||
let n = next() + next() + next() + next() - 2.0;
|
||||
base.get(i % (t * t)).copied().unwrap_or(0.0) + 0.05 * n
|
||||
})
|
||||
.collect();
|
||||
(input, base)
|
||||
}
|
||||
|
||||
/// A tile for checking that two runners of one model agree: the whole range a raw photo has rather than the test tile's
|
||||
/// mid-greys — a noisy ramp from black to white across the tile, a flat black corner and a clipped white one (four
|
||||
/// planes of `tile × tile`).
|
||||
pub fn check_tile(t: usize) -> Vec<f32> {
|
||||
if t == 0 || t > 2048 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
let (edge, span) = ((t / 8).max(1), (2 * t).saturating_sub(2).max(1) as f32);
|
||||
(0..4 * t * t)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % (t * t)) % t, (i % (t * t)) / t);
|
||||
let n = next() + next() + next() + next() - 2.0;
|
||||
if x < edge && y < edge {
|
||||
0.0
|
||||
} else if x + edge >= t && y + edge >= t {
|
||||
1.0
|
||||
} else {
|
||||
((x + y) as f32 / span + 0.05 * n).clamp(0.0, 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::licence::Licence;
|
||||
use crate::manifest::Domain;
|
||||
use crate::synthetic_proto::{len_field, value_info_bytes};
|
||||
|
||||
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
fn node(op: &str, inputs: &[&str], output: &str, ints: &[(&str, i64)]) -> Vec<u8> {
|
||||
let mut n = Vec::new();
|
||||
for i in inputs {
|
||||
len_field(1, i.as_bytes(), &mut n);
|
||||
}
|
||||
len_field(2, output.as_bytes(), &mut n);
|
||||
len_field(4, op.as_bytes(), &mut n);
|
||||
for (name, value) in ints {
|
||||
let mut a = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut a);
|
||||
varint_field(3, *value as u64, &mut a);
|
||||
varint_field(20, 2, &mut a);
|
||||
len_field(5, &a, &mut n);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for d in dims {
|
||||
varint_field(1, *d, &mut t);
|
||||
}
|
||||
varint_field(2, 1, &mut t);
|
||||
len_field(8, name.as_bytes(), &mut t);
|
||||
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
len_field(9, &raw, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// A tiny working denoiser-shaped network: `[1, 4, t, t]` → a 1 × 1 convolution to 12 channels (each output
|
||||
/// channel is `scale` times one input plane, so the scale is known) → depth-to-space → `[1, 3, 2t, 2t]`.
|
||||
fn tiny_model(t: u64, scale: f32) -> Vec<u8> {
|
||||
tiny_model_with(t, scale, 12)
|
||||
}
|
||||
|
||||
/// The same with another number of convolution channels; only 12 can be arranged into the three colours.
|
||||
fn tiny_model_with(t: u64, scale: f32, channels: u64) -> Vec<u8> {
|
||||
let mut graph = Vec::new();
|
||||
len_field(1, &node("Conv", &["data", "w"], if channels == 12 { "c" } else { "rgb" }, &[("group", 1)]), &mut graph);
|
||||
if channels == 12 {
|
||||
let mut depth_to_space = node("DepthToSpace", &["c"], "rgb", &[("blocksize", 2)]);
|
||||
let mut mode = Vec::new();
|
||||
len_field(1, b"mode", &mut mode);
|
||||
len_field(4, b"CRD", &mut mode);
|
||||
varint_field(20, 3, &mut mode);
|
||||
len_field(5, &mode, &mut depth_to_space);
|
||||
len_field(1, &depth_to_space, &mut graph);
|
||||
}
|
||||
// CRD output channel colour·4 + dy·2 + dx reads input plane [R, G1, B] of the tile.
|
||||
let mut w = vec![0f32; channels as usize * 4];
|
||||
for oc in 0..channels as usize {
|
||||
let colour = (oc / 4).min(2);
|
||||
let plane = [0, 1, 3][colour];
|
||||
w[oc * 4 + plane] = scale;
|
||||
}
|
||||
len_field(5, &tensor("w", &[channels, 4, 1, 1], &w), &mut graph);
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(t), Ok(t)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * t), Ok(2 * t)]), &mut graph);
|
||||
let mut ops = Vec::new();
|
||||
len_field(1, b"", &mut ops);
|
||||
varint_field(2, 13, &mut ops);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
||||
fn manifest(tile: u32, nominal: f32) -> DenoiserManifest {
|
||||
DenoiserManifest {
|
||||
id: "tiny".into(),
|
||||
name: "Tiny".into(),
|
||||
version: "1".into(),
|
||||
licence: Licence::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile,
|
||||
overlap: 16,
|
||||
gain: Gain::MatchMean { nominal, max_deviation: 0.05 },
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_model(name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("lc-denoise-runtime-{name}-{}.onnx", std::process::id()));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_model_loads_runs_and_has_the_scale_it_says() {
|
||||
let p = temp_model("ok", &tiny_model(64, 1000.0));
|
||||
let r = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap();
|
||||
let input: Vec<f32> = (0..4 * 64 * 64).map(|i| (i % 97) as f32 / 97.0).collect();
|
||||
let out = r.run(&input).unwrap();
|
||||
assert_eq!(out.len(), 3 * 128 * 128);
|
||||
// output (colour c, pixel (2y+dy, 2x+dx)) = scale × input plane [R, G1, B][c] at (y, x)
|
||||
for (c, plane) in [0usize, 1, 3].into_iter().enumerate() {
|
||||
let (y, x) = (5usize, 9usize);
|
||||
assert!((out[c * 128 * 128 + (2 * y + 1) * 128 + 2 * x] - 1000.0 * input[plane * 64 * 64 + y * 64 + x]).abs() < 1e-2);
|
||||
}
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_self_test_checks_scale_and_noise() {
|
||||
// a model that only multiplies cannot take noise down: it must fail that check but pass the others
|
||||
let p = temp_model("selftest", &tiny_model(64, 1000.0));
|
||||
let t = CpuRunner::load(&p, &manifest(64, 1000.0)).unwrap().self_test();
|
||||
assert!(!t.ok, "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("same answer") && *ok), "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("scale") && *ok), "{t:?}");
|
||||
assert!(t.checks.iter().any(|(c, ok)| c.contains("noise") && !*ok), "{t:?}");
|
||||
// and one that claims the wrong scale fails that check
|
||||
let q = CpuRunner::load(&p, &manifest(64, 5.0)).unwrap().self_test();
|
||||
assert!(q.checks.iter().any(|(c, ok)| c.contains("scale") && !*ok), "{q:?}");
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_sizes_and_broken_files_are_errors() {
|
||||
let p = temp_model("sizes", &tiny_model(64, 1.0));
|
||||
let r = CpuRunner::load(&p, &manifest(64, 1.0)).unwrap();
|
||||
assert!(matches!(r.run(&[0.0; 10]), Err(Error::Input(_))));
|
||||
// a fully convolutional model works at whatever tile size the manifest asks for
|
||||
let wide = CpuRunner::load(&p, &manifest(128, 1.0)).unwrap();
|
||||
assert_eq!(wide.run(&vec![0.5; 4 * 128 * 128]).unwrap().len(), 3 * 256 * 256);
|
||||
// a model that gives another number of values than the manifest promises is an error
|
||||
let q = temp_model("channels", &tiny_model_with(64, 1.0, 8));
|
||||
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "shape is refused before execution");
|
||||
let _ = std::fs::remove_file(q);
|
||||
for (name, bytes) in [("junk", b"nonsense".to_vec()), ("empty", Vec::new())] {
|
||||
let q = temp_model(name, &bytes);
|
||||
assert!(CpuRunner::load(&q, &manifest(64, 1.0)).is_err(), "{name}");
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
assert!(CpuRunner::load(&std::env::temp_dir().join("does-not-exist.onnx"), &manifest(64, 1.0)).is_err());
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_tile_covers_black_to_white() {
|
||||
for t in [1, 2, 7, 64] {
|
||||
let c = check_tile(t);
|
||||
assert_eq!(c.len(), 4 * t * t);
|
||||
assert!(c.iter().all(|v| (0.0..=1.0).contains(v)), "{t}");
|
||||
}
|
||||
let c = check_tile(64);
|
||||
let near = |lo: f32, hi: f32| c.iter().filter(|v| (lo..=hi).contains(*v)).count();
|
||||
assert!(near(0.0, 0.0) >= 4 * 8 * 8 && near(1.0, 1.0) >= 4 * 8 * 8, "a flat black and a clipped white corner");
|
||||
for band in 0..10 {
|
||||
let lo = band as f32 / 10.0;
|
||||
assert!(near(lo, lo + 0.1) > 100, "values around {lo}");
|
||||
}
|
||||
assert_eq!(check_tile(64), c, "the same tile every time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupted_models_never_panic() {
|
||||
let good = tiny_model(64, 1.0);
|
||||
for n in (0..good.len()).step_by(5) {
|
||||
let q = temp_model("trunc", &good[..n]);
|
||||
let _ = CpuRunner::load(&q, &manifest(64, 1.0));
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
for at in (0..good.len()).step_by(7) {
|
||||
let mut bad = good.clone();
|
||||
if let Some(b) = bad.get_mut(at) {
|
||||
*b = b.wrapping_add(0x55);
|
||||
}
|
||||
let q = temp_model("flip", &bad);
|
||||
if let Ok(r) = CpuRunner::load(&q, &manifest(64, 1.0)) {
|
||||
let _ = r.run(&vec![0.5; 4 * 64 * 64]);
|
||||
}
|
||||
let _ = std::fs::remove_file(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: `LC_DENOISE_MODEL=<model_bayer.onnx> cargo test -p lightcraft-denoise --release
|
||||
/// -- --ignored --nocapture` self-tests a real model (RawNIND's Bayer model: tile 512, scale about 1e6).
|
||||
#[test]
|
||||
#[ignore = "needs a real model file: set LC_DENOISE_MODEL"]
|
||||
fn a_real_model_passes_the_self_test() {
|
||||
let Some(path) = std::env::var_os("LC_DENOISE_MODEL") else { return };
|
||||
let m = manifest(512, 1.0e6);
|
||||
let r = CpuRunner::load(Path::new(&path), &m).unwrap();
|
||||
let t = r.self_test();
|
||||
println!("load {:.0} ms | {:.0} ms per tile | scale {:.4e} | {:?}", t.load_ms, t.tile_ms, t.scale, t.checks);
|
||||
assert!(t.ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn manifest_and_test_tile_sizes_are_checked_without_tract() {
|
||||
for t in [0, 2049, usize::MAX] {
|
||||
assert!(test_tile(t).0.is_empty());
|
||||
assert!(check_tile(t).is_empty());
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("lc-cpu-load-{}.onnx", std::process::id()));
|
||||
std::fs::write(&path, crate::synthetic::unet_onnx(64, 5, 1, 7)).unwrap();
|
||||
let mut m = DenoiserManifest {
|
||||
id: "synthetic".into(),
|
||||
name: "Synthetic".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: "synthetic test".into(),
|
||||
domain: crate::manifest::Domain::BayerToRgb,
|
||||
tile: 64,
|
||||
overlap: 16,
|
||||
gain: Gain::None,
|
||||
};
|
||||
let r = CpuRunner::load(&path, &m).unwrap();
|
||||
let x = check_tile(64);
|
||||
assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap());
|
||||
assert!(r.self_test().checks.iter().any(|(text, ok)| text.contains("same answer") && *ok));
|
||||
m.tile = u32::MAX;
|
||||
assert!(CpuRunner::load(&path, &m).is_err());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! A small ONNX U-Net built in memory, for tests of the layers that read and run denoise networks (here, in the GPU
|
||||
//! runner and in the engine): the same layer kinds and wiring as the real denoisers, with deterministic random weights.
|
||||
//! Nothing here is a model anyone would use.
|
||||
|
||||
use crate::synthetic_proto::{len_field, value_info_bytes};
|
||||
|
||||
fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
enum A<'a> {
|
||||
Int(i64),
|
||||
Ints(&'a [i64]),
|
||||
Float(f32),
|
||||
Str(&'a str),
|
||||
}
|
||||
|
||||
fn node(op: &str, inputs: &[&str], output: &str, attrs: &[(&str, A)]) -> Vec<u8> {
|
||||
let mut n = Vec::new();
|
||||
for i in inputs {
|
||||
len_field(1, i.as_bytes(), &mut n);
|
||||
}
|
||||
len_field(2, output.as_bytes(), &mut n);
|
||||
len_field(4, op.as_bytes(), &mut n);
|
||||
for (name, value) in attrs {
|
||||
let mut a = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut a);
|
||||
match value {
|
||||
A::Int(v) => {
|
||||
varint_field(3, *v as u64, &mut a);
|
||||
varint_field(20, 2, &mut a);
|
||||
}
|
||||
A::Ints(vs) => {
|
||||
for v in *vs {
|
||||
varint_field(8, *v as u64, &mut a);
|
||||
}
|
||||
varint_field(20, 7, &mut a);
|
||||
}
|
||||
A::Float(v) => {
|
||||
varint((2 << 3) | 5, &mut a);
|
||||
a.extend_from_slice(&v.to_le_bytes());
|
||||
varint_field(20, 1, &mut a);
|
||||
}
|
||||
A::Str(s) => {
|
||||
len_field(4, s.as_bytes(), &mut a);
|
||||
varint_field(20, 3, &mut a);
|
||||
}
|
||||
}
|
||||
len_field(5, &a, &mut n);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn tensor(name: &str, dims: &[u64], values: &[f32]) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for d in dims {
|
||||
varint_field(1, *d, &mut t);
|
||||
}
|
||||
varint_field(2, 1, &mut t);
|
||||
len_field(8, name.as_bytes(), &mut t);
|
||||
let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
len_field(9, &raw, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
nodes: Vec<Vec<u8>>,
|
||||
inits: Vec<Vec<u8>>,
|
||||
rng: Rng,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn name(&mut self, kind: &str) -> String {
|
||||
self.n += 1;
|
||||
format!("{kind}{}", self.n)
|
||||
}
|
||||
|
||||
/// Random weights scaled so activations keep a sane size through the layers.
|
||||
fn weights(&mut self, name: &str, dims: &[u64], fan_in: usize) -> String {
|
||||
let count: u64 = dims.iter().product();
|
||||
let scale = (3.0 / fan_in as f32).sqrt();
|
||||
let values: Vec<f32> = (0..count).map(|_| self.rng.next() * scale).collect();
|
||||
self.inits.push(tensor(name, dims, &values));
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
fn bias(&mut self, name: &str, n: u64) -> String {
|
||||
let values: Vec<f32> = (0..n).map(|_| self.rng.next() * 0.1).collect();
|
||||
self.inits.push(tensor(name, &[n], &values));
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// `Conv` (+ `LeakyRelu` when `leaky`); gives the name of the tensor that results.
|
||||
fn conv(&mut self, src: &str, cin: u64, cout: u64, k: u64, leaky: bool) -> String {
|
||||
let (w, b, out) = (self.name("w"), self.name("b"), self.name("conv"));
|
||||
let w = self.weights(&w, &[cout, cin, k, k], (cin * k * k) as usize);
|
||||
let b = self.bias(&b, cout);
|
||||
let pad = (k as i64 - 1) / 2;
|
||||
self.nodes.push(node(
|
||||
"Conv",
|
||||
&[src, &w, &b],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[k as i64, k as i64])), ("pads", A::Ints(&[pad; 4])), ("strides", A::Ints(&[1, 1])), ("group", A::Int(1))],
|
||||
));
|
||||
if !leaky {
|
||||
return out;
|
||||
}
|
||||
let act = self.name("act");
|
||||
self.nodes.push(node("LeakyRelu", &[&out], &act, &[("alpha", A::Float(0.2))]));
|
||||
act
|
||||
}
|
||||
|
||||
fn pool(&mut self, src: &str) -> String {
|
||||
let out = self.name("pool");
|
||||
self.nodes.push(node(
|
||||
"MaxPool",
|
||||
&[src],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn up(&mut self, src: &str, cin: u64, cout: u64) -> String {
|
||||
let (w, b, out) = (self.name("uw"), self.name("ub"), self.name("up"));
|
||||
let w = self.weights(&w, &[cin, cout, 2, 2], (cin * 4) as usize);
|
||||
let b = self.bias(&b, cout);
|
||||
self.nodes.push(node(
|
||||
"ConvTranspose",
|
||||
&[src, &w, &b],
|
||||
&out,
|
||||
&[("kernel_shape", A::Ints(&[2, 2])), ("strides", A::Ints(&[2, 2])), ("pads", A::Ints(&[0; 4]))],
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn concat(&mut self, a: &str, b: &str) -> String {
|
||||
let out = self.name("cat");
|
||||
self.nodes.push(node("Concat", &[a, b], &out, &[("axis", A::Int(1))]));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A denoiser-shaped U-Net as ONNX bytes: `[1, 4, tile, tile]` → `[1, 3, 2·tile, 2·tile]`, with `depth` poolings
|
||||
/// (so `tile` must be a multiple of `2^depth`) and `ch` channels on the first level, doubling on each (every count a
|
||||
/// multiple of 4). Each level is two 3 × 3 convolutions with a leaky ReLU, as in the real networks; the output is a
|
||||
/// 1 × 1 convolution to 12 channels and depth-to-space.
|
||||
pub fn unet_onnx(tile: u64, ch: u64, depth: usize, seed: u64) -> Vec<u8> {
|
||||
let mut b = Builder { nodes: Vec::new(), inits: Vec::new(), rng: Rng(seed | 1), n: 0 };
|
||||
let mut skips: Vec<(String, u64)> = Vec::new();
|
||||
let (mut cur, mut c_in, mut c) = ("data".to_string(), 4u64, ch);
|
||||
for level in 0..=depth {
|
||||
let x = b.conv(&cur, c_in, c, 3, true);
|
||||
let x = b.conv(&x, c, c, 3, true);
|
||||
if level < depth {
|
||||
skips.push((x.clone(), c));
|
||||
cur = b.pool(&x);
|
||||
c_in = c;
|
||||
c *= 2;
|
||||
} else {
|
||||
cur = x;
|
||||
}
|
||||
}
|
||||
for (skip, sc) in skips.into_iter().rev() {
|
||||
let up = b.up(&cur, c, sc);
|
||||
let joined = b.concat(&up, &skip);
|
||||
let x = b.conv(&joined, 2 * sc, sc, 3, true);
|
||||
cur = b.conv(&x, sc, sc, 3, true);
|
||||
c = sc;
|
||||
}
|
||||
let rgb = b.conv(&cur, c, 12, 1, false);
|
||||
b.nodes.push(node("DepthToSpace", &[&rgb], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]));
|
||||
|
||||
let mut graph = Vec::new();
|
||||
for n in &b.nodes {
|
||||
len_field(1, n, &mut graph);
|
||||
}
|
||||
for t in &b.inits {
|
||||
len_field(5, t, &mut graph);
|
||||
}
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(tile), Ok(tile)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(2 * tile), Ok(2 * tile)]), &mut graph);
|
||||
let mut ops = Vec::new();
|
||||
len_field(1, b"", &mut ops);
|
||||
varint_field(2, 13, &mut ops);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
|
||||
/// A tiny smoothing network for installing a real CPU model in setup-flow tests.
|
||||
/// It averages a 3 × 3 neighborhood of the four Bayer planes and returns three identical channels.
|
||||
pub fn smoothing_onnx() -> Vec<u8> {
|
||||
let mut graph = Vec::new();
|
||||
len_field(1, &node("Conv", &["data", "weights"], "samples", &[("kernel_shape", A::Ints(&[3, 3])), ("pads", A::Ints(&[1, 1, 1, 1]))]), &mut graph);
|
||||
len_field(1, &node("DepthToSpace", &["samples"], "rgb", &[("blocksize", A::Int(2)), ("mode", A::Str("CRD"))]), &mut graph);
|
||||
len_field(5, &tensor("weights", &[12, 4, 3, 3], &[1.0 / 36.0; 12 * 4 * 3 * 3]), &mut graph);
|
||||
len_field(11, &value_info_bytes("data", 1, &[Ok(1), Ok(4), Ok(64), Ok(64)]), &mut graph);
|
||||
len_field(12, &value_info_bytes("rgb", 1, &[Ok(1), Ok(3), Ok(128), Ok(128)]), &mut graph);
|
||||
let mut model = Vec::new();
|
||||
varint_field(1, 8, &mut model);
|
||||
len_field(7, &graph, &mut model);
|
||||
let mut ops = Vec::new();
|
||||
varint_field(2, 13, &mut ops);
|
||||
len_field(8, &ops, &mut model);
|
||||
model
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
pub(crate) fn varint(mut v: u64, out: &mut Vec<u8>) {
|
||||
loop {
|
||||
let b = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
if v == 0 {
|
||||
out.push(b);
|
||||
return;
|
||||
}
|
||||
out.push(b | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len_field(n: u64, body: &[u8], out: &mut Vec<u8>) {
|
||||
varint(n << 3 | 2, out);
|
||||
varint(body.len() as u64, out);
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
|
||||
pub(crate) fn varint_field(n: u64, v: u64, out: &mut Vec<u8>) {
|
||||
varint(n << 3, out);
|
||||
varint(v, out);
|
||||
}
|
||||
|
||||
/// A graph input or output: `Ok(n)` is a fixed dimension, `Err(name)` a named (dynamic) one.
|
||||
pub fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result<u64, &str>]) -> Vec<u8> {
|
||||
let mut shape = Vec::new();
|
||||
for d in dims {
|
||||
let mut dim = Vec::new();
|
||||
match d {
|
||||
Ok(v) => varint_field(1, *v, &mut dim),
|
||||
Err(p) => len_field(2, p.as_bytes(), &mut dim),
|
||||
}
|
||||
len_field(1, &dim, &mut shape);
|
||||
}
|
||||
let mut tensor = Vec::new();
|
||||
varint_field(1, elem, &mut tensor);
|
||||
len_field(2, &shape, &mut tensor);
|
||||
let mut ty = Vec::new();
|
||||
len_field(1, &tensor, &mut ty);
|
||||
let mut vi = Vec::new();
|
||||
len_field(1, name.as_bytes(), &mut vi);
|
||||
len_field(2, &ty, &mut vi);
|
||||
vi
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Opt-in end-to-end check of the real RawNIND Bayer model on real mosaics with noise added:
|
||||
//!
|
||||
//! ```text
|
||||
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
|
||||
//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! `LC_DENOISE_MOSAICS` holds `<name>.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white
|
||||
//! balanced), `<name>.rgb.f32` (a clean demosaic of it, camera RGB, interleaved) and `<name>.txt` (`width`,
|
||||
//! `height`, `cfa`, `wb` lines). Noise is Poisson-Gaussian, made here. Optional: `LC_DENOISE_PARALLEL` (tiles at
|
||||
//! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none),
|
||||
//! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop).
|
||||
|
||||
#![cfg(feature = "runtime")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_denoise::bayer::Layout;
|
||||
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
|
||||
use lightcraft_denoise::run::{Control, Params, denoise_bayer};
|
||||
use lightcraft_denoise::runtime::CpuRunner;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
|
||||
}
|
||||
fn gauss(&mut self) -> f64 {
|
||||
(-2.0 * self.next().ln()).sqrt() * (std::f64::consts::TAU * self.next()).cos()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_f32(path: &Path) -> Vec<f32> {
|
||||
std::fs::read(path).unwrap().as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
|
||||
}
|
||||
|
||||
struct Dump {
|
||||
width: usize,
|
||||
height: usize,
|
||||
layout: Layout,
|
||||
wb: [f32; 3],
|
||||
mosaic: Vec<f32>,
|
||||
rgb: Vec<f32>,
|
||||
}
|
||||
|
||||
fn load(dir: &Path, name: &str) -> Dump {
|
||||
let text = std::fs::read_to_string(dir.join(format!("{name}.txt"))).unwrap();
|
||||
let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(k).map(|v| v.trim().to_string())).unwrap();
|
||||
let cell = match field("cfa").as_str() {
|
||||
"RGGB" => [0, 1, 1, 2],
|
||||
"BGGR" => [2, 1, 1, 0],
|
||||
"GRBG" => [1, 0, 2, 1],
|
||||
"GBRG" => [1, 2, 0, 1],
|
||||
other => panic!("cfa {other}"),
|
||||
};
|
||||
let wb: Vec<f32> = field("wb").split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
Dump {
|
||||
width: field("width").parse().unwrap(),
|
||||
height: field("height").parse().unwrap(),
|
||||
layout: Layout::from_cell(cell).unwrap(),
|
||||
wb: [wb[0], wb[1], wb[2]],
|
||||
mosaic: read_f32(&dir.join(format!("{name}.mosaic.f32"))),
|
||||
rgb: read_f32(&dir.join(format!("{name}.rgb.f32"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop with an even origin so the Bayer phase is kept.
|
||||
fn crop(d: &Dump, x0: usize, y0: usize, w: usize, h: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut m = Vec::with_capacity(w * h);
|
||||
let mut c = Vec::with_capacity(w * h * 3);
|
||||
for y in y0..y0 + h {
|
||||
m.extend_from_slice(&d.mosaic[y * d.width + x0..y * d.width + x0 + w]);
|
||||
c.extend_from_slice(&d.rgb[(y * d.width + x0) * 3..(y * d.width + x0 + w) * 3]);
|
||||
}
|
||||
(m, c)
|
||||
}
|
||||
|
||||
/// 0 = red, 1 = green, 2 = blue at a mosaic position.
|
||||
fn site(layout: Layout, x: usize, y: usize) -> usize {
|
||||
match ((x + layout.dx) % 2, (y + layout.dy) % 2) {
|
||||
(0, 0) => 0,
|
||||
(1, 1) => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The simplest demosaic (each missing colour is the mean of the same-coloured samples in the 3 × 3 around),
|
||||
/// as the baseline a user would get from the noisy picture without AI.
|
||||
fn bilinear(m: &[f32], w: usize, h: usize, layout: Layout) -> Vec<f32> {
|
||||
let mut out = vec![0f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut sum = [0f32; 3];
|
||||
let mut n = [0f32; 3];
|
||||
for yy in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for xx in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
let c = site(layout, xx, yy);
|
||||
sum[c] += m[yy * w + xx];
|
||||
n[c] += 1.0;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
out[(y * w + x) * 3 + c] = if n[c] > 0.0 { sum[c] / n[c] } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Signal-to-error ratio in dB of `got` against `want` (both interleaved RGB), ignoring a border.
|
||||
fn snr(got: &[f32], want: &[f32], w: usize, h: usize, border: usize) -> f64 {
|
||||
let (mut sig, mut err) = (0f64, 0f64);
|
||||
for y in border..h - border {
|
||||
for x in border..w - border {
|
||||
for c in 0..3 {
|
||||
let (g, t) = (f64::from(got[(y * w + x) * 3 + c]), f64::from(want[(y * w + x) * 3 + c]));
|
||||
sig += t * t;
|
||||
err += (g - t).powi(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
10.0 * (sig / err.max(1e-30)).log10()
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// A viewable PNG of interleaved camera-RGB panels side by side: white balanced, one exposure for all, gamma.
|
||||
fn write_png(path: &Path, panels: &[&[f32]], w: usize, h: usize, wb: [f32; 3], exposure: f32) {
|
||||
let total_w = w * panels.len();
|
||||
let mut raw = Vec::with_capacity((total_w * 3 + 1) * h);
|
||||
for y in 0..h {
|
||||
raw.push(0u8);
|
||||
for p in panels {
|
||||
for x in 0..w {
|
||||
for c in 0..3 {
|
||||
let v = (p[(y * w + x) * 3 + c] * wb[c] * exposure).clamp(0.0, 1.0);
|
||||
raw.push((v.powf(1.0 / 2.2) * 255.0 + 0.5) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut chunk = |kind: &[u8; 4], body: &[u8]| {
|
||||
png.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
let mut k = kind.to_vec();
|
||||
k.extend_from_slice(body);
|
||||
png.extend_from_slice(&k);
|
||||
png.extend_from_slice(&crc32(&k).to_be_bytes());
|
||||
};
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&(total_w as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
|
||||
chunk(b"IHDR", &ihdr);
|
||||
chunk(b"IDAT", &z);
|
||||
chunk(b"IEND", &[]);
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a real model and mosaics: see the module docs"]
|
||||
fn the_real_model_denoises_real_mosaics() {
|
||||
let (Some(model), Some(dir)) = (std::env::var_os("LC_DENOISE_MODEL"), std::env::var_os("LC_DENOISE_MOSAICS")) else { return };
|
||||
let dir = PathBuf::from(dir);
|
||||
let out_dir = std::env::var_os("LC_DENOISE_OUT").map(PathBuf::from);
|
||||
let parallel: usize = std::env::var("LC_DENOISE_PARALLEL").ok().and_then(|v| v.parse().ok()).unwrap_or(8);
|
||||
let iso: f64 = std::env::var("LC_DENOISE_ISO").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let names: Vec<String> = match std::env::var("LC_DENOISE_ONLY") {
|
||||
Ok(n) => vec![n],
|
||||
Err(_) => ["arw-sony-a7m3-compressed", "cr2-canon-6d", "nef-nikon-d5100-lossless", "dng-google-pixel2xl", "pef-pentax-k3"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
};
|
||||
let manifest = DenoiserManifest {
|
||||
id: "rawnind-bayer".into(),
|
||||
name: "RawNIND Bayer".into(),
|
||||
version: "1".into(),
|
||||
licence: Default::default(),
|
||||
source: None,
|
||||
sha256: None,
|
||||
size_bytes: None,
|
||||
provenance: String::new(),
|
||||
domain: Domain::BayerToRgb,
|
||||
tile: 512,
|
||||
overlap: 64,
|
||||
gain: Gain::MatchMean { nominal: 1.0e6, max_deviation: 0.05 },
|
||||
};
|
||||
let runner = CpuRunner::load(Path::new(&model), &manifest).unwrap();
|
||||
let params = Params { tile: 512, overlap: 64, gain: manifest.gain, clip: Some(1.0), parallel };
|
||||
for name in names {
|
||||
let d = load(&dir, &name);
|
||||
// a crop of about 2 × 1.5 tiles, away from the edges, even origin
|
||||
let full = std::env::var_os("LC_DENOISE_FULL").is_some();
|
||||
let (w, h) = if full { (d.width & !1, d.height & !1) } else { (2048usize, 1536usize) };
|
||||
let (x0, y0) = (((d.width - w) / 3) & !1, ((d.height - h) / 2) & !1);
|
||||
let (clean, reference) = crop(&d, x0, y0, w, h);
|
||||
// Poisson-Gaussian noise: variance a·signal + b, as a sensor at high ISO
|
||||
let (a, b) = (1.5e-3 * iso, (4.0e-3 * iso.sqrt()).powi(2));
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ name.len() as u64);
|
||||
let noisy: Vec<f32> = clean
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
let v = f64::from(v);
|
||||
let sigma = (a * v.max(0.0) + b).sqrt();
|
||||
((v + sigma * rng.gauss()) as f32).min(1.0)
|
||||
})
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
let done = std::sync::atomic::AtomicUsize::new(0);
|
||||
let progress = |n: usize, total: usize| done.store(n * 1000 / total, std::sync::atomic::Ordering::Relaxed);
|
||||
let denoised = denoise_bayer(&noisy, w, h, d.layout, &runner, ¶ms, &Control { cancel: None, progress: Some(&progress) }).unwrap();
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
let baseline = bilinear(&noisy, w, h, d.layout);
|
||||
let clean_bilinear = bilinear(&clean, w, h, d.layout);
|
||||
let den: Vec<f32> = denoised.data.iter().flat_map(|p| p.iter().copied()).collect();
|
||||
let (s_noisy, s_den) = (snr(&baseline, &reference, w, h, 64), snr(&den, &reference, w, h, 64));
|
||||
let s_floor = snr(&clean_bilinear, &reference, w, h, 64);
|
||||
println!(
|
||||
"{name}: {w}×{h} crop, {} tiles, parallel {parallel}, {secs:.1} s | SNR vs clean demosaic: noisy bilinear {s_noisy:.2} dB, denoised {s_den:.2} dB (clean bilinear {s_floor:.2} dB)",
|
||||
lightcraft_denoise::run::tile_count(w, h, d.layout, 512, 64)
|
||||
);
|
||||
if let Some(out) = &out_dir {
|
||||
std::fs::create_dir_all(out).unwrap();
|
||||
let mean = reference.iter().map(|&v| f64::from(v)).sum::<f64>() / reference.len() as f64;
|
||||
let exposure = (0.18 / mean.max(1e-6)) as f32;
|
||||
// three panels, a 512 × 384 window each, at 1:1
|
||||
let (pw, ph, px, py) = (512usize, 384usize, 700usize, 500usize);
|
||||
let window =
|
||||
|buf: &[f32]| -> Vec<f32> { (0..ph).flat_map(|y| buf[((py + y) * w + px) * 3..((py + y) * w + px + pw) * 3].to_vec()).collect() };
|
||||
write_png(&out.join(format!("{name}.png")), &[&window(&baseline), &window(&den), &window(&reference)], pw, ph, d.wb, exposure);
|
||||
}
|
||||
if iso >= 4.0 {
|
||||
assert!(s_den > s_noisy + 2.0, "{name}: denoised {s_den:.2} dB is not clearly better than noisy {s_noisy:.2} dB");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -207,6 +207,7 @@ controls! {
|
||||
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
|
||||
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
|
||||
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
|
||||
"enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
|
||||
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
|
||||
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
pub mod controls;
|
||||
pub mod presets;
|
||||
pub mod segmask;
|
||||
pub mod settings;
|
||||
|
||||
pub use controls::{CONTROLS, ControlSpec, Section, Track};
|
||||
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
|
||||
pub use segmask::SegMask;
|
||||
pub use settings::*;
|
||||
|
||||
use serde_json::Value;
|
||||
@@ -66,6 +68,39 @@ impl DevelopSettings {
|
||||
!self.disabled_sections.iter().any(|s| s == section)
|
||||
}
|
||||
|
||||
/// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
|
||||
/// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
|
||||
/// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
|
||||
/// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
|
||||
pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
|
||||
let off = |section: &str| !self.section_enabled(section);
|
||||
if !(off("light") || off("color") || off("detail")) {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
if off("light") {
|
||||
d.reset_section(Section::Light);
|
||||
d.reset_section(Section::Curve);
|
||||
}
|
||||
if off("color") {
|
||||
for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
|
||||
d.reset_section(section);
|
||||
}
|
||||
d.point_colors.clear();
|
||||
}
|
||||
if off("detail") {
|
||||
d.reset_section(Section::Detail);
|
||||
}
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// How much of the AI-denoised picture to mix in, 0 (none) to 1 (all of it): the Denoise amount, unless the Detail
|
||||
/// section is switched off or the value is not a number.
|
||||
pub fn denoise_amount(&self) -> f32 {
|
||||
let a = self.enhance.denoise;
|
||||
if a.is_finite() && self.enhance.denoise_enabled() && self.section_enabled("detail") { (a / 100.0).clamp(0.0, 1.0) as f32 } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
|
||||
self.disabled_sections.retain(|s| s != section);
|
||||
if !on {
|
||||
@@ -84,6 +119,7 @@ impl DevelopSettings {
|
||||
self.curve = d;
|
||||
}
|
||||
Section::Color => self.wb.mode = WbMode::AsShot,
|
||||
Section::Detail => self.enhance.denoise_on = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -165,4 +201,40 @@ mod tests {
|
||||
s.set_section_enabled("effects", true);
|
||||
assert!(s.section_enabled("effects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_denoise_amount_is_a_slider_and_follows_the_detail_section() {
|
||||
let mut s = DevelopSettings::default();
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
assert!(controls::set(&mut s, "enhance.denoise", 60.0));
|
||||
assert_eq!(controls::get(&s, "enhance.denoise"), Some(60.0));
|
||||
assert!((s.denoise_amount() - 0.6).abs() < 1e-6);
|
||||
// the slider's range is 0 to 100; values outside it are brought in
|
||||
controls::set(&mut s, "enhance.denoise", 500.0);
|
||||
assert_eq!(s.denoise_amount(), 1.0);
|
||||
controls::set(&mut s, "enhance.denoise", -5.0);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
// switching the Detail section off switches the denoise off with it; a settings file with a bad number does nothing
|
||||
controls::set(&mut s, "enhance.denoise", 80.0);
|
||||
s.set_section_enabled("detail", false);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
s.set_section_enabled("detail", true);
|
||||
s.enhance.denoise = f64::NAN;
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
// it is part of Detail: a reset of the section clears it
|
||||
controls::set(&mut s, "enhance.denoise", 40.0);
|
||||
s.reset_section(Section::Detail);
|
||||
assert_eq!(s.enhance.denoise, 0.0);
|
||||
// The later independent switch preserves Amount when off, and can be on at zero.
|
||||
controls::set(&mut s, "enhance.denoise", 75.0);
|
||||
s.enhance.denoise_on = Some(false);
|
||||
assert_eq!(s.denoise_amount(), 0.0);
|
||||
assert_eq!(s.enhance.denoise, 75.0);
|
||||
s.enhance.denoise_on = Some(true);
|
||||
assert_eq!(s.denoise_amount(), 0.75);
|
||||
controls::set(&mut s, "enhance.denoise", 0.0);
|
||||
assert!(s.enhance.denoise_enabled());
|
||||
s.reset_section(Section::Detail);
|
||||
assert_eq!(s.enhance.denoise_on, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Model segmentations stored with a mask ([`SegMask`]), so rendering never needs the model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Logits are stored in steps of 1/`SCALE`, clamped to ±127/`SCALE` (≈ ±15.9: a sigmoid of
|
||||
/// 1 − 1.2e-7, i.e. fully in or out).
|
||||
const SCALE: f32 = 8.0;
|
||||
/// Largest grid a stored segmentation may claim (hostile input caps the allocation).
|
||||
pub const MAX_SIDE: usize = 1024;
|
||||
|
||||
/// A segmentation computed by a model (SAM 3 in `lightcraft-segment`): `side × side` logits
|
||||
/// over the uncropped, oriented image stretched to a square (row `y`, column `x` covers
|
||||
/// normalized `((x + 0.5) / side, (y + 0.5) / side)`), quantized to `i8`, deflated and base64
|
||||
/// encoded. Coverage at a point is the sigmoid of the bilinearly sampled logit, so edges stay
|
||||
/// smooth at any output size.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SegMask {
|
||||
pub side: u32,
|
||||
pub data: String,
|
||||
/// The part of the image the grid covers, normalized `[x0, y0, x1, y1]` (`None` = all of
|
||||
/// it). A zoomed-in pass over one object covers just its surroundings, at a higher
|
||||
/// resolution than a pass over the whole photo.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rect: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl SegMask {
|
||||
/// From `side × side` logits over the whole image.
|
||||
pub fn from_logits(side: usize, logits: &[f32]) -> SegMask {
|
||||
let q: Vec<u8> = logits.iter().take(side * side).map(|l| ((l * SCALE).round().clamp(-127.0, 127.0) as i8) as u8).collect();
|
||||
let z = miniz_oxide::deflate::compress_to_vec(&q, 8);
|
||||
SegMask { side: side as u32, data: base64_encode(&z), rect: None }
|
||||
}
|
||||
|
||||
/// From `side × side` logits over the part `rect` of the image.
|
||||
pub fn from_logits_in(side: usize, logits: &[f32], rect: [f64; 4]) -> SegMask {
|
||||
SegMask { rect: Some(rect), ..SegMask::from_logits(side, logits) }
|
||||
}
|
||||
|
||||
/// The covered part, normalized (the whole image without a `rect`); `None` when damaged.
|
||||
pub fn bounds(&self) -> Option<[f64; 4]> {
|
||||
let r = self.rect.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
|
||||
}
|
||||
|
||||
/// The logits, or `None` when the data is damaged or claims an absurd size.
|
||||
pub fn logits(&self) -> Option<Vec<f32>> {
|
||||
let side = self.side as usize;
|
||||
if side == 0 || side > MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
let z = base64_decode(&self.data)?;
|
||||
let q = miniz_oxide::inflate::decompress_to_vec_with_limit(&z, side * side).ok()?;
|
||||
(q.len() == side * side).then(|| q.iter().map(|b| f32::from(*b as i8) / SCALE).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Most zoomed-in detail patches one AI mask component keeps (the app makes one; each costs a
|
||||
/// decode per render, so a hostile document can't make rendering explode).
|
||||
pub const MAX_DETAIL: usize = 4;
|
||||
|
||||
/// Deserialize a component's detail patches, keeping at most [`MAX_DETAIL`] (the rest are
|
||||
/// skipped without being decoded).
|
||||
pub(crate) fn de_detail<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<SegMask>, D::Error> {
|
||||
struct Capped;
|
||||
impl<'de> serde::de::Visitor<'de> for Capped {
|
||||
type Value = Vec<SegMask>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of segmentations")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<SegMask>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < MAX_DETAIL {
|
||||
match seq.next_element::<SegMask>()? {
|
||||
Some(m) => out.push(m),
|
||||
None => return Ok(out),
|
||||
}
|
||||
}
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Capped)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(char::from(B64[((n >> (18 - 6 * i)) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace()) {
|
||||
if c == b'=' {
|
||||
break;
|
||||
}
|
||||
let v = B64.iter().position(|b| *b == c)? as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_round_trips() {
|
||||
for n in 0..10 {
|
||||
let v: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&v)).unwrap(), v);
|
||||
}
|
||||
assert_eq!(base64_encode(b"Man"), "TWFu");
|
||||
assert_eq!(base64_encode(b"Ma"), "TWE=");
|
||||
assert!(base64_decode("TW!u").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logits_round_trip_quantized() {
|
||||
let side = 16;
|
||||
let l: Vec<f32> = (0..side * side).map(|i| (i as f32 - 128.0) / 4.0).collect();
|
||||
let m = SegMask::from_logits(side, &l);
|
||||
let back = m.logits().unwrap();
|
||||
for (a, b) in l.iter().zip(&back) {
|
||||
assert!((a.clamp(-127.0 / SCALE, 127.0 / SCALE) - b).abs() <= 0.5 / SCALE + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_document_keeps_at_most_max_detail_patches() {
|
||||
let patch = serde_json::to_value(SegMask::from_logits(4, &[1.0; 16])).unwrap();
|
||||
let shape = serde_json::json!({"kind": "object", "hint": [{"x": 0.5, "y": 0.5}], "detail": vec![patch; 10_000]});
|
||||
let s: crate::MaskShape = serde_json::from_value(shape).unwrap();
|
||||
let crate::MaskShape::Object { detail, .. } = s else { panic!("an Object") };
|
||||
assert_eq!(detail.len(), MAX_DETAIL);
|
||||
// and a whole develop document with them still loads
|
||||
let mut d = crate::DevelopSettings::default();
|
||||
d.masks.push(crate::Mask {
|
||||
id: 1,
|
||||
components: vec![crate::MaskComponent {
|
||||
name: None,
|
||||
op: crate::MaskOp::Add,
|
||||
invert: false,
|
||||
shape: crate::MaskShape::Prompt { text: "x".into(), seg: None, detail: vec![SegMask::from_logits(4, &[1.0; 16]); 50], edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let back: crate::DevelopSettings = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
|
||||
let crate::MaskShape::Prompt { detail, .. } = &back.masks[0].components[0].shape else { panic!("a Prompt") };
|
||||
assert_eq!(detail.len(), MAX_DETAIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_data_is_none() {
|
||||
let mut m = SegMask::from_logits(8, &[1.0; 64]);
|
||||
m.side = 9;
|
||||
assert!(m.logits().is_none(), "size mismatch");
|
||||
m.side = 100_000;
|
||||
assert!(m.logits().is_none(), "absurd size");
|
||||
assert!(SegMask { side: 8, data: "not base64 !".into(), rect: None }.logits().is_none());
|
||||
assert!(SegMask { rect: Some([0.5, 0.0, 0.2, 1.0]), ..SegMask::from_logits(2, &[0.0; 4]) }.bounds().is_none());
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,16 @@ pub struct Profile {
|
||||
pub id: String,
|
||||
/// Creative profile amount 0..200 (%).
|
||||
pub amount: f64,
|
||||
/// Render the raw's own local tone mapping (DNG `ProfileGainTableMap`, Apple ProRAW), as the
|
||||
/// camera does. Off: rendered as Lightroom Classic does, without it. Omitted when off, so
|
||||
/// settings without it keep their JSON (and hash).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub camera_local_tone: bool,
|
||||
}
|
||||
|
||||
impl Default for Profile {
|
||||
fn default() -> Self {
|
||||
Self { id: "lc.color".into(), amount: 100.0 }
|
||||
Self { id: "lc.color".into(), amount: 100.0, camera_local_tone: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +163,7 @@ impl WbMode {
|
||||
pub struct WhiteBalance {
|
||||
pub mode: WbMode,
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
@@ -539,8 +545,9 @@ impl Default for Optics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted
|
||||
/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop
|
||||
/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3`
|
||||
/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic /
|
||||
/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop
|
||||
/// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts.
|
||||
/// LightCraft never uses Adobe LCP lens profiles.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -772,8 +779,33 @@ pub enum MaskShape {
|
||||
Subject,
|
||||
Sky,
|
||||
Background,
|
||||
/// One object picked by clicks (SAM 3 point prompts): `hint` holds the clicks that include,
|
||||
/// `exclude` the ones that exclude; `seg` the segmentation computed from them.
|
||||
Object {
|
||||
hint: Vec<Point>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
exclude: Vec<Point>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seg: Option<crate::SegMask>,
|
||||
/// Zoomed-in passes over parts of the image (higher resolution than `seg`), used
|
||||
/// inside their rectangles.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
|
||||
detail: Vec<crate::SegMask>,
|
||||
/// Edge −100..100: below 0 harder (a steeper transition), above 0 softer (feathered).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
edge: f64,
|
||||
},
|
||||
/// Everything a description names ("sky", "the red car": SAM 3 concept prompts); `seg` is
|
||||
/// the segmentation computed for it.
|
||||
Prompt {
|
||||
text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seg: Option<crate::SegMask>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::segmask::de_detail")]
|
||||
detail: Vec<crate::SegMask>,
|
||||
/// Edge −100..100, as for `Object`.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
edge: f64,
|
||||
},
|
||||
People {
|
||||
person: u32,
|
||||
@@ -888,6 +920,14 @@ pub struct LensBlur {
|
||||
#[serde(default)]
|
||||
pub struct Enhance {
|
||||
pub denoise: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub denoise_on: Option<bool>,
|
||||
pub raw_details: bool,
|
||||
pub super_resolution: bool,
|
||||
}
|
||||
|
||||
impl Enhance {
|
||||
pub fn denoise_enabled(&self) -> bool {
|
||||
self.denoise_on.unwrap_or(self.denoise > 0.0)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,10 @@ web-time = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true, optional = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
@@ -30,6 +33,21 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
unicode-segmentation = "1.13"
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
# model downloads run natively only (the web build fetches no models)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
lightcraft-fetch = { workspace = true }
|
||||
|
||||
[features]
|
||||
# AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds.
|
||||
sam = ["dep:lightcraft-segment"]
|
||||
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-gpu/denoise"]
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
libc = { workspace = true }
|
||||
lightcraft-tiff = { workspace = true }
|
||||
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user